From 60a74b48778928d429036c38361cdc150c627b62 Mon Sep 17 00:00:00 2001
From: Tobias Hagemann
Date: Fri, 5 Jun 2026 07:24:44 +0200
Subject: [PATCH 1/4] Migrate Hub billing from store to api and add invoice
checkout
---
assets/js/hubsubscription.js | 344 +++++++++++++-----------
data/de/eu_countries.yaml | 54 ++++
data/en/eu_countries.yaml | 54 ++++
i18n/de.yaml | 65 ++++-
i18n/en.yaml | 65 ++++-
layouts/hub-billing/single.html | 303 ++++++++++++++-------
layouts/partials/hub-license-block.html | 27 ++
7 files changed, 644 insertions(+), 268 deletions(-)
create mode 100644 data/de/eu_countries.yaml
create mode 100644 data/en/eu_countries.yaml
create mode 100644 layouts/partials/hub-license-block.html
diff --git a/assets/js/hubsubscription.js b/assets/js/hubsubscription.js
index 2edc6d4cf..771e1a4d1 100644
--- a/assets/js/hubsubscription.js
+++ b/assets/js/hubsubscription.js
@@ -1,10 +1,11 @@
"use strict";
-const BILLING_PORTAL_SESSION_URL = LEGACY_STORE_URL + '/hub/billing-portal-session';
+const CARD_CHECKOUT_URL = API_BASE_URL + '/paddle-classic/checkout';
+const INVOICE_CHECKOUT_URL = API_BASE_URL + '/espocrm/checkout';
+const MANAGE_SUBSCRIPTION_BASE_URL = API_BASE_URL + '/manage/subscription';
+const SESSION_REQUEST_URL = API_BASE_URL + '/manage/session/request';
+const SESSION_CONFIRM_URL = API_BASE_URL + '/manage/session/confirm';
const CUSTOM_BILLING_URL = LEGACY_STORE_URL + '/hub/custom-billing';
-const GENERATE_PAY_LINK_URL = LEGACY_STORE_URL + '/hub/generate-pay-link';
-const MANAGE_SUBSCRIPTION_URL = LEGACY_STORE_URL + '/hub/manage-subscription';
-const UPDATE_PAYMENT_METHOD_URL = LEGACY_STORE_URL + '/hub/update-payment-method';
const REFRESH_LICENSE_URL = API_BASE_URL + '/licenses/hub/refresh';
class HubSubscription {
@@ -15,23 +16,25 @@ class HubSubscription {
let fragmentParams = new URLSearchParams(location.hash.substring(1));
this._subscriptionData.oldLicense = fragmentParams.get('oldLicense');
if (this._subscriptionData.oldLicense) {
- try {
- let base64 = this._subscriptionData.oldLicense.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
- this._subscriptionData.hubId = JSON.parse(atob(base64)).jti;
- } catch (e) {
- console.error('Failed to parse hub token:', e);
+ this._subscriptionData.hubId = this.extractHubId(this._subscriptionData.oldLicense);
+ if (!this._subscriptionData.hubId) {
this._subscriptionData.oldLicense = null;
}
}
- this._subscriptionData.hubId = this._subscriptionData.hubId ?? searchParams.get('hub_id');
+ this._subscriptionData.hubId = this._subscriptionData.hubId ?? fragmentParams.get('hub_id') ?? searchParams.get('hub_id');
let returnUrl = fragmentParams.get('returnUrl') ?? searchParams.get('return_url');
if (returnUrl) {
this._subscriptionData.returnUrl = returnUrl;
}
- this._subscriptionData.session = searchParams.get('session');
- if (this._subscriptionData.hubId && this._subscriptionData.hubId.length > 0 && this._subscriptionData.returnUrl && this._subscriptionData.returnUrl.length > 0) {
+ let nonce = fragmentParams.get('nonce');
+ if (nonce) {
+ this.restoreHubContext();
+ history.replaceState(null, '', location.pathname + location.search);
this._subscriptionData.state = 'LOADING';
- this.loadSubscription();
+ this.confirmSession(nonce);
+ } else if (this._subscriptionData.hubId && this._subscriptionData.hubId.length > 0 && this._subscriptionData.returnUrl && this._subscriptionData.returnUrl.length > 0) {
+ this._subscriptionData.state = 'LOADING';
+ this.loadCheckoutPrerequisites();
}
this._paddle = $.ajax({
url: 'https://cdn.paddle.com/paddle/paddle.js',
@@ -46,47 +49,86 @@ class HubSubscription {
});
}
- loadSubscription() {
+ extractHubId(license) {
+ try {
+ let base64 = license.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(base64)).jti;
+ } catch (e) {
+ console.error('Failed to parse hub token:', e);
+ return null;
+ }
+ }
+
+ authHeaders() {
+ return { Authorization: 'Bearer ' + this._subscriptionData.sessionToken };
+ }
+
+ restoreHubContext() {
+ let hubId = this._subscriptionData.hubId;
+ let oldLicense = sessionStorage.getItem(`hubOldLicense:${hubId}`);
+ if (oldLicense) {
+ this._subscriptionData.oldLicense = oldLicense;
+ }
+ let returnUrl = sessionStorage.getItem(`hubReturnUrl:${hubId}`);
+ if (returnUrl) {
+ this._subscriptionData.returnUrl = returnUrl;
+ }
+ }
+
+ loadCheckoutPrerequisites() {
this.loadCustomBilling(() => {
+ if (this._subscriptionData.customBilling?.manual_invoice) {
+ this._subscriptionData.state = 'MANUAL_INVOICE';
+ return;
+ }
this.loadPrice(() => {
- this._subscriptionData.inProgress = true;
+ this._subscriptionData.state = 'NEW_CUSTOMER';
this._subscriptionData.errorMessage = '';
- $.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
- type: 'GET',
- data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session
- }
- }).done(data => {
- this.onLoadSubscriptionSucceeded(data);
- }).fail(xhr => {
- this.onLoadSubscriptionFailed(xhr.status, xhr.responseJSON?.message || 'Loading subscription failed.');
- });
+ this._subscriptionData.inProgress = false;
+ });
+ });
+ }
+
+ loadManageSubscription() {
+ this.loadCustomBilling(() => {
+ this._subscriptionData.inProgress = true;
+ this._subscriptionData.errorMessage = '';
+ $.ajax({
+ url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}`,
+ type: 'GET',
+ headers: this.authHeaders()
+ }).done(data => {
+ this.onLoadSubscriptionSucceeded(data);
+ }).fail(xhr => {
+ this.onLoadSubscriptionFailed(xhr.status, xhr.responseJSON?.message || 'Loading subscription failed.');
});
});
}
onLoadSubscriptionSucceeded(data) {
- this._subscriptionData.oldLicense = data.token;
- this._subscriptionData.details = data.subscription;
- if (data.subscription.quantity) {
- this._subscriptionData.quantity = data.subscription.quantity;
- }
+ this._subscriptionData.details = {
+ processor: data.processor,
+ status: data.status,
+ seats: data.seats,
+ current_period_end: data.current_period_end
+ };
+ this._subscriptionData.quantity = data.seats;
this._subscriptionData.state = 'EXISTING_CUSTOMER';
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
- this._subscriptionData.needsTokenRefresh = true;
+ this._subscriptionData.needsTokenRefresh = !!this._subscriptionData.oldLicense;
}
onLoadSubscriptionFailed(status, error) {
- if (status == 404) {
- this._subscriptionData.state = 'NEW_CUSTOMER';
- this._subscriptionData.errorMessage = '';
- } else if (status == 400) {
- // Assuming that the error is due to the session being missing.
+ if (status == 401) {
this._subscriptionData.state = 'CREATE_SESSION';
this._subscriptionData.errorMessage = '';
+ } else if (status == 404 && this._subscriptionData.returnUrl) {
+ this.loadCheckoutPrerequisites();
+ return;
+ } else if (status == 404) {
+ this._subscriptionData.state = 'MISSING_PARAMS';
+ this._subscriptionData.errorMessage = '';
} else {
this._subscriptionData.state = 'CREATE_SESSION';
this._subscriptionData.errorMessage = error;
@@ -101,20 +143,26 @@ class HubSubscription {
return;
}
+ let hubId = this._subscriptionData.hubId;
+ if (this._subscriptionData.oldLicense) {
+ sessionStorage.setItem(`hubOldLicense:${hubId}`, this._subscriptionData.oldLicense);
+ }
+ if (this._subscriptionData.returnUrl) {
+ sessionStorage.setItem(`hubReturnUrl:${hubId}`, this._subscriptionData.returnUrl);
+ }
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: BILLING_PORTAL_SESSION_URL,
+ url: SESSION_REQUEST_URL,
type: 'POST',
data: {
captcha: this._subscriptionData.captcha,
- hub_id: this._subscriptionData.hubId,
- return_url: this._subscriptionData.returnUrl
+ hub_id: this._subscriptionData.hubId
}
}).done(_ => {
this.onCreateSessionSucceeded();
}).fail(xhr => {
- this.onCreateSessionFailed(xhr.responseJSON?.message || 'Creating billing portal session failed.');
+ this.onCreateSessionFailed(xhr.responseJSON?.message || 'Requesting access failed.');
});
}
@@ -129,6 +177,29 @@ class HubSubscription {
this._subscriptionData.errorMessage = error;
}
+ confirmSession(nonce) {
+ this._subscriptionData.inProgress = true;
+ this._subscriptionData.errorMessage = '';
+ $.ajax({
+ url: SESSION_CONFIRM_URL,
+ type: 'POST',
+ data: {
+ nonce: nonce
+ }
+ }).done(data => {
+ this._subscriptionData.sessionToken = data.token;
+ this.loadManageSubscription();
+ }).fail(xhr => {
+ this.onConfirmSessionFailed(xhr.responseJSON?.message || 'Confirming access failed.');
+ });
+ }
+
+ onConfirmSessionFailed(error) {
+ this._subscriptionData.state = 'CREATE_SESSION';
+ this._subscriptionData.errorMessage = error;
+ this._subscriptionData.inProgress = false;
+ }
+
loadCustomBilling(continueHandler) {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
@@ -140,11 +211,7 @@ class HubSubscription {
}
}).done(data => {
this.onLoadCustomBillingSucceeded(data);
- if (data.custom_billing.manual_invoice) {
- this._subscriptionData.state = 'MANUAL_INVOICE';
- } else {
- continueHandler();
- }
+ continueHandler();
}).fail(xhr => {
this.onLoadCustomBillingFailed(xhr.status, xhr.responseJSON?.message || 'Loading custom billing options failed.');
if (xhr.status == 404 && xhr.responseJSON?.status == 'error') {
@@ -242,6 +309,16 @@ class HubSubscription {
this._subscriptionData.inProgress = false;
}
+ selectedPlanId() {
+ let isManaged = this._subscriptionData.customBilling?.managed;
+ let isMonthly = this._subscriptionData.billingInterval === 'monthly';
+ if (isManaged) {
+ return isMonthly ? PADDLE_HUB_MANAGED_MONTHLY_PLAN_ID : PADDLE_HUB_MANAGED_YEARLY_PLAN_ID;
+ } else {
+ return isMonthly ? PADDLE_HUB_SELF_HOSTED_MONTHLY_PLAN_ID : PADDLE_HUB_SELF_HOSTED_YEARLY_PLAN_ID;
+ }
+ }
+
checkout(locale) {
if (!$(this._form)[0].checkValidity()) {
$(this._form).find(':input').addClass('show-invalid');
@@ -251,22 +328,15 @@ class HubSubscription {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
- let isManaged = this._subscriptionData.customBilling?.managed;
- let isMonthly = this._subscriptionData.billingInterval === 'monthly';
- let planId;
- if (isManaged) {
- planId = isMonthly ? PADDLE_HUB_MANAGED_MONTHLY_PLAN_ID : PADDLE_HUB_MANAGED_YEARLY_PLAN_ID;
- } else {
- planId = isMonthly ? PADDLE_HUB_SELF_HOSTED_MONTHLY_PLAN_ID : PADDLE_HUB_SELF_HOSTED_YEARLY_PLAN_ID;
- }
- this.customCheckout(planId, locale);
+ this.customCheckout(this.selectedPlanId(), locale);
}
customCheckout(productId, locale) {
$.ajax({
- url: GENERATE_PAY_LINK_URL,
+ url: CARD_CHECKOUT_URL,
type: 'POST',
data: {
+ captcha: this._subscriptionData.cardCaptcha,
hub_id: this._subscriptionData.hubId,
product_id: productId,
quantity: this._subscriptionData.quantity
@@ -274,7 +344,7 @@ class HubSubscription {
}).done(data => {
this.openPaddleCheckout(data.pay_link, locale);
}).fail(xhr => {
- this.onPostFailed(xhr.responseJSON?.message || 'Generating pay link failed.');
+ this.onPostFailed(xhr.responseJSON?.message || 'Checkout failed.');
});
}
@@ -298,7 +368,7 @@ class HubSubscription {
paddle.Order.details(checkoutId, data => {
let subscriptionId = data.order.subscription_id;
if (subscriptionId) {
- this.post(subscriptionId);
+ this.onCheckoutSucceeded();
} else {
this._subscriptionData.errorMessage = 'Retrieving subscription failed. Please check your emails instead.';
}
@@ -306,35 +376,49 @@ class HubSubscription {
});
}
- post(subscriptionId) {
+ invoiceCheckout() {
+ if (!$(this._form)[0].checkValidity()) {
+ $(this._form).find(':input').addClass('show-invalid');
+ this._subscriptionData.errorMessage = 'Please fill in all required fields.';
+ return;
+ }
+
+ this._subscriptionData.inProgress = true;
+ this._subscriptionData.errorMessage = '';
+ let invoice = this._subscriptionData.invoice;
$.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
+ url: INVOICE_CHECKOUT_URL,
type: 'POST',
data: {
+ captcha: this._subscriptionData.invoiceCaptcha,
hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
- subscription_id: subscriptionId
+ product_id: this.selectedPlanId(),
+ quantity: this._subscriptionData.quantity,
+ account_name: invoice.account_name,
+ vat_id: invoice.vat_id,
+ address_street: invoice.address_street,
+ address_postal_code: invoice.address_postal_code,
+ address_city: invoice.address_city,
+ address_country: invoice.address_country,
+ contact_first_name: invoice.contact_first_name,
+ contact_last_name: invoice.contact_last_name,
+ contact_email: invoice.contact_email
}
- }).done(data => {
- this.onPostSucceeded(data);
+ }).done(_ => {
+ this.onCheckoutSucceeded();
}).fail(xhr => {
- this.onPostFailed(xhr.responseJSON?.message || 'Adding subscription failed.');
+ this.onPostFailed(xhr.responseJSON?.message || 'Creating subscription failed.');
});
}
- onPostSucceeded(data) {
- this._subscriptionData.state = 'EXISTING_CUSTOMER';
- this._subscriptionData.oldLicense = data.token;
- this._subscriptionData.details = data.subscription;
- this._subscriptionData.session = data.session;
- var searchParams = new URLSearchParams(window.location.search)
- searchParams.set('session', data.session);
- var newRelativePathQuery = window.location.pathname + '?' + searchParams.toString();
- history.pushState(null, '', newRelativePathQuery);
+ onCheckoutSucceeded() {
+ this._subscriptionData.state = 'CHECKOUT_SUCCESS';
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
- this._subscriptionData.shouldTransferToHub = true;
- this._subscriptionData.needsTokenRefresh = true;
+ if (this._subscriptionData.oldLicense) {
+ this._subscriptionData.needsTokenRefresh = true;
+ this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl;
+ }
}
onPostFailed(error) {
@@ -346,19 +430,15 @@ class HubSubscription {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: UPDATE_PAYMENT_METHOD_URL,
+ url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/payment-method`,
type: 'GET',
- data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
- subscription_id: this._subscriptionData.details.subscription_id
- }
+ headers: this.authHeaders()
}).done(data => {
this._paddle.then(paddle => {
paddle.Checkout.open({
override: data.url,
locale: locale,
- successCallback: _ => this.loadSubscription(),
+ successCallback: _ => this.loadManageSubscription(),
closeCallback: () => {
this._subscriptionData.inProgress = false;
}
@@ -373,68 +453,39 @@ class HubSubscription {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
- type: 'PUT',
- data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
- pause: true
- }
- }).done(data => {
- this.onPutSucceeded(data, false);
+ url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/pause`,
+ type: 'POST',
+ headers: this.authHeaders()
+ }).done(_ => {
+ this.loadManageSubscription();
}).fail(xhr => {
this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating subscription failed.');
});
}
askForRestartConfirmation() {
- this._subscriptionData.restartModal.nextPayment = null;
this._subscriptionData.restartModal.open = true;
- this.previewRestart();
- }
-
- previewRestart() {
- this._subscriptionData.inProgress = true;
- this._subscriptionData.errorMessage = '';
- $.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
- type: 'PUT',
- data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
- pause: false,
- preview: true
- }
- }).done(data => {
- this._subscriptionData.restartModal.nextPayment = data.subscription.next_payment;
- this._subscriptionData.errorMessage = '';
- this._subscriptionData.inProgress = false;
- }).fail(xhr => {
- this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Calculating price failed.');
- });
}
restart() {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
- type: 'PUT',
- data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
- pause: false
- }
- }).done(data => {
- this.onPutSucceeded(data, this._subscriptionData.details.state == 'paused');
+ url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/resume`,
+ type: 'POST',
+ headers: this.authHeaders()
+ }).done(_ => {
+ this._subscriptionData.restartModal.open = false;
+ this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl;
+ this.loadManageSubscription();
}).fail(xhr => {
this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating subscription failed.');
});
}
openChangeSeatsModal() {
- this._subscriptionData.quantity = this._subscriptionData.details.quantity;
- this._subscriptionData.changeSeatsModal.immediatePayment = null;
+ this._subscriptionData.quantity = this._subscriptionData.details.seats;
+ this._subscriptionData.changeSeatsModal.nextPayment = null;
this._subscriptionData.changeSeatsModal.confirmation = false;
this._subscriptionData.changeSeatsModal.open = true;
}
@@ -447,23 +498,23 @@ class HubSubscription {
}
this._subscriptionData.changeSeatsModal.confirmation = true;
- this.previewChangeQuantity();
+ if (this._subscriptionData.details.processor == 'PADDLE_CLASSIC') {
+ this.previewChangeQuantity();
+ }
}
previewChangeQuantity() {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
- type: 'PUT',
+ url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/seats/preview`,
+ type: 'POST',
+ headers: this.authHeaders(),
data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
- quantity: this._subscriptionData.quantity,
- preview: true
+ quantity: this._subscriptionData.quantity
}
}).done(data => {
- this._subscriptionData.changeSeatsModal.immediatePayment = data.subscription.immediate_payment;
+ this._subscriptionData.changeSeatsModal.nextPayment = data.next_payment;
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
}).fail(xhr => {
@@ -475,32 +526,21 @@ class HubSubscription {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: MANAGE_SUBSCRIPTION_URL,
- type: 'PUT',
+ url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/seats`,
+ type: 'POST',
+ headers: this.authHeaders(),
data: {
- hub_id: this._subscriptionData.hubId,
- session: this._subscriptionData.session,
quantity: this._subscriptionData.quantity
}
- }).done(data => {
+ }).done(_ => {
this._subscriptionData.changeSeatsModal.open = false;
- this.onPutSucceeded(data, true);
+ this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl;
+ this.loadManageSubscription();
}).fail(xhr => {
this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating subscription failed.');
});
}
- onPutSucceeded(data, shouldOpenReturnUrl) {
- this._subscriptionData.oldLicense = data.token;
- this._subscriptionData.details = data.subscription;
- this._subscriptionData.errorMessage = '';
- this._subscriptionData.inProgress = false;
- if (shouldOpenReturnUrl) {
- this._subscriptionData.shouldTransferToHub = true;
- this._subscriptionData.needsTokenRefresh = true;
- }
- }
-
onPutFailed(status, error) {
if (status == 401) {
this._subscriptionData.state = 'CREATE_SESSION';
diff --git a/data/de/eu_countries.yaml b/data/de/eu_countries.yaml
new file mode 100644
index 000000000..fd975739e
--- /dev/null
+++ b/data/de/eu_countries.yaml
@@ -0,0 +1,54 @@
+- code: AT
+ name: Österreich
+- code: BE
+ name: Belgien
+- code: BG
+ name: Bulgarien
+- code: HR
+ name: Kroatien
+- code: CY
+ name: Zypern
+- code: CZ
+ name: Tschechien
+- code: DK
+ name: Dänemark
+- code: EE
+ name: Estland
+- code: FI
+ name: Finnland
+- code: FR
+ name: Frankreich
+- code: DE
+ name: Deutschland
+- code: GR
+ name: Griechenland
+- code: HU
+ name: Ungarn
+- code: IE
+ name: Irland
+- code: IT
+ name: Italien
+- code: LV
+ name: Lettland
+- code: LT
+ name: Litauen
+- code: LU
+ name: Luxemburg
+- code: MT
+ name: Malta
+- code: NL
+ name: Niederlande
+- code: PL
+ name: Polen
+- code: PT
+ name: Portugal
+- code: RO
+ name: Rumänien
+- code: SK
+ name: Slowakei
+- code: SI
+ name: Slowenien
+- code: ES
+ name: Spanien
+- code: SE
+ name: Schweden
diff --git a/data/en/eu_countries.yaml b/data/en/eu_countries.yaml
new file mode 100644
index 000000000..c0f1063df
--- /dev/null
+++ b/data/en/eu_countries.yaml
@@ -0,0 +1,54 @@
+- code: AT
+ name: Austria
+- code: BE
+ name: Belgium
+- code: BG
+ name: Bulgaria
+- code: HR
+ name: Croatia
+- code: CY
+ name: Cyprus
+- code: CZ
+ name: Czechia
+- code: DK
+ name: Denmark
+- code: EE
+ name: Estonia
+- code: FI
+ name: Finland
+- code: FR
+ name: France
+- code: DE
+ name: Germany
+- code: GR
+ name: Greece
+- code: HU
+ name: Hungary
+- code: IE
+ name: Ireland
+- code: IT
+ name: Italy
+- code: LV
+ name: Latvia
+- code: LT
+ name: Lithuania
+- code: LU
+ name: Luxembourg
+- code: MT
+ name: Malta
+- code: NL
+ name: Netherlands
+- code: PL
+ name: Poland
+- code: PT
+ name: Portugal
+- code: RO
+ name: Romania
+- code: SK
+ name: Slovakia
+- code: SI
+ name: Slovenia
+- code: ES
+ name: Spain
+- code: SE
+ name: Sweden
diff --git a/i18n/de.yaml b/i18n/de.yaml
index 11ade0766..b27d002b7 100644
--- a/i18n/de.yaml
+++ b/i18n/de.yaml
@@ -513,8 +513,6 @@
translation: "Status"
- id: hub_billing_manage_status_active
translation: "Aktiv"
-- id: hub_billing_manage_status_pastdue
- translation: "Überfällig"
- id: hub_billing_manage_status_trialing
translation: "Testphase"
- id: hub_billing_manage_status_paused
@@ -537,12 +535,10 @@
- id: hub_billing_manage_payment_info_title
translation: "Zahlungsinformationen"
-- id: hub_billing_manage_payment_info_credit_card
- translation: "Kreditkarte"
-- id: hub_billing_manage_payment_info_credit_card_last_four_digits_description
- translation: "Endet mit"
-- id: hub_billing_manage_payment_info_paypal
- translation: "PayPal"
+- id: hub_billing_manage_payment_info_card
+ translation: "Karte / PayPal"
+- id: hub_billing_manage_payment_info_invoice
+ translation: "Rechnung"
- id: hub_billing_manage_payment_info_update_action
translation: "Zahlungsmethode aktualisieren"
@@ -555,8 +551,6 @@
- id: hub_billing_manage_license_key_retry_action
translation: "Erneut versuchen"
-- id: hub_billing_manage_modal_charge_amount_description
- translation: "Rechnungsbetrag"
- id: hub_billing_manage_modal_continue
translation: "Weiter"
- id: hub_billing_manage_modal_confirm
@@ -579,8 +573,12 @@
translation: "Neue Anzahl der Sitze"
- id: hub_billing_manage_change_quantity_confirmation_increase_warning
translation: "Du bist dabei, das Sitzplatzlimit zu erhöhen. Wenn du dies bestätigst, wird die die Differenz sofort in Rechnung gestellt."
+- id: hub_billing_manage_change_quantity_confirmation_increase_warning_invoice
+ translation: "Du bist dabei, das Sitzplatzlimit zu erhöhen. Wenn du dies bestätigst, werden die zusätzlichen Sitze per Rechnung abgerechnet."
- id: hub_billing_manage_change_quantity_confirmation_decrease_warning
translation: "Du bist dabei, das Sitzplatzlimit zu verringern. Wenn du dies bestätigst, wird deine nächste Zahlung um die Differenz reduziert."
+- id: hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice
+ translation: "Du bist dabei, das Sitzplatzlimit zu verringern. Wenn du dies bestätigst, wird die Reduzierung auf deiner nächsten Rechnung berücksichtigt."
- id: hub_billing_checkout_description
translation: "Schöpfe das volle Potenzial deiner Hub-Instanz aus und hol dein Team mit der clientseitigen Verschlüsselung für deinen Cloud-Speicher an Bord."
@@ -588,6 +586,8 @@
translation: "Für Unternehmen"
- id: hub_billing_checkout_audience_consumer
translation: "Für zu Hause"
+- id: hub_billing_checkout_manage_existing_action
+ translation: "Ein bestehendes Abonnement verwalten"
- id: hub_billing_checkout_standard_title
translation: "Standard"
@@ -619,6 +619,51 @@
- id: hub_billing_checkout_standard_submit
translation: "Zur Zahlung"
+- id: hub_billing_checkout_payment_method
+ translation: "Zahlungsmethode"
+- id: hub_billing_checkout_payment_method_card
+ translation: "Per Karte zahlen"
+- id: hub_billing_checkout_payment_method_invoice
+ translation: "Kauf auf Rechnung"
+
+- id: hub_billing_checkout_invoice_contact_first_name
+ translation: "Vorname"
+- id: hub_billing_checkout_invoice_contact_last_name
+ translation: "Nachname"
+- id: hub_billing_checkout_invoice_contact_email
+ translation: "E-Mail"
+- id: hub_billing_checkout_invoice_contact_email_hint
+ translation: "Mit dieser E-Mail-Adresse verwaltest du später dein Abonnement."
+- id: hub_billing_checkout_invoice_account_name
+ translation: "Firmenname"
+- id: hub_billing_checkout_invoice_address_street
+ translation: "Straße und Hausnummer"
+- id: hub_billing_checkout_invoice_address_postal_code
+ translation: "Postleitzahl"
+- id: hub_billing_checkout_invoice_address_city
+ translation: "Stadt"
+- id: hub_billing_checkout_invoice_address_country
+ translation: "Land"
+- id: hub_billing_checkout_invoice_address_country_placeholder
+ translation: "Bitte auswählen"
+- id: hub_billing_checkout_invoice_non_eu_hint
+ translation: "Kauf auf Rechnung ist nur innerhalb der EU möglich. Außerhalb der EU? Kontaktiere uns bitte über die Enterprise-Option."
+- id: hub_billing_checkout_invoice_vat_id
+ translation: "USt-IdNr."
+- id: hub_billing_checkout_invoice_vat_id_hint
+ translation: "Erforderlich für EU-Länder außerhalb Deutschlands."
+- id: hub_billing_checkout_invoice_instruction
+ translation: "Eine Rechnung wird ausgestellt und an deine E-Mail-Adresse gesendet."
+- id: hub_billing_checkout_invoice_submit
+ translation: "Auf Rechnung kaufen"
+
+- id: hub_billing_checkout_success_description
+ translation: "Vielen Dank für deinen Kauf! Dein Abonnement ist jetzt aktiv."
+- id: hub_billing_checkout_success_invoice_description
+ translation: "Eine Rechnung wird ausgestellt und an deine E-Mail-Adresse gesendet."
+- id: hub_billing_checkout_success_relicense_description
+ translation: "Um deine Lizenz zu erhalten, kehre bitte zu deiner Hub-Instanz zurück und starte den Abonnementvorgang erneut."
+
- id: hub_billing_checkout_community_title
translation: "Community"
- id: hub_billing_checkout_community_statement
diff --git a/i18n/en.yaml b/i18n/en.yaml
index 7dd8b483d..da784f39f 100644
--- a/i18n/en.yaml
+++ b/i18n/en.yaml
@@ -513,8 +513,6 @@
translation: "Status"
- id: hub_billing_manage_status_active
translation: "Active"
-- id: hub_billing_manage_status_pastdue
- translation: "Past Due"
- id: hub_billing_manage_status_trialing
translation: "Trialing"
- id: hub_billing_manage_status_paused
@@ -537,12 +535,10 @@
- id: hub_billing_manage_payment_info_title
translation: "Payment Information"
-- id: hub_billing_manage_payment_info_credit_card
- translation: "Credit Card"
-- id: hub_billing_manage_payment_info_credit_card_last_four_digits_description
- translation: "Ending with"
-- id: hub_billing_manage_payment_info_paypal
- translation: "PayPal"
+- id: hub_billing_manage_payment_info_card
+ translation: "Card / PayPal"
+- id: hub_billing_manage_payment_info_invoice
+ translation: "Invoice"
- id: hub_billing_manage_payment_info_update_action
translation: "Update Payment Method"
@@ -555,8 +551,6 @@
- id: hub_billing_manage_license_key_retry_action
translation: "Retry"
-- id: hub_billing_manage_modal_charge_amount_description
- translation: "Charge Amount"
- id: hub_billing_manage_modal_continue
translation: "Continue"
- id: hub_billing_manage_modal_confirm
@@ -579,8 +573,12 @@
translation: "New Number of Seats"
- id: hub_billing_manage_change_quantity_confirmation_increase_warning
translation: "You are about to increase the seats limit. By confirming, you will be immediately charged for the difference."
+- id: hub_billing_manage_change_quantity_confirmation_increase_warning_invoice
+ translation: "You are about to increase the seats limit. By confirming, the additional seats will be billed by invoice."
- id: hub_billing_manage_change_quantity_confirmation_decrease_warning
translation: "You are about to decrease the seats limit. By confirming, your next payment will be reduced by the difference."
+- id: hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice
+ translation: "You are about to decrease the seats limit. By confirming, the reduction will be reflected on your next invoice."
- id: hub_billing_checkout_description
translation: "Unlock the full potential of your Hub instance and get your team on board with client-side encryption for your cloud storage."
@@ -588,6 +586,8 @@
translation: "For Business"
- id: hub_billing_checkout_audience_consumer
translation: "For Home"
+- id: hub_billing_checkout_manage_existing_action
+ translation: "Manage an existing subscription"
- id: hub_billing_checkout_standard_title
translation: "Standard"
@@ -619,6 +619,51 @@
- id: hub_billing_checkout_standard_submit
translation: "Checkout"
+- id: hub_billing_checkout_payment_method
+ translation: "Payment Method"
+- id: hub_billing_checkout_payment_method_card
+ translation: "Pay by Card"
+- id: hub_billing_checkout_payment_method_invoice
+ translation: "Pay by Invoice"
+
+- id: hub_billing_checkout_invoice_contact_first_name
+ translation: "First Name"
+- id: hub_billing_checkout_invoice_contact_last_name
+ translation: "Last Name"
+- id: hub_billing_checkout_invoice_contact_email
+ translation: "Email"
+- id: hub_billing_checkout_invoice_contact_email_hint
+ translation: "You'll use this email address to manage your subscription later."
+- id: hub_billing_checkout_invoice_account_name
+ translation: "Company Name"
+- id: hub_billing_checkout_invoice_address_street
+ translation: "Street and Number"
+- id: hub_billing_checkout_invoice_address_postal_code
+ translation: "Postal Code"
+- id: hub_billing_checkout_invoice_address_city
+ translation: "City"
+- id: hub_billing_checkout_invoice_address_country
+ translation: "Country"
+- id: hub_billing_checkout_invoice_address_country_placeholder
+ translation: "Please select"
+- id: hub_billing_checkout_invoice_non_eu_hint
+ translation: "Invoice payment is available within the EU only. Outside the EU? Please contact us via the Enterprise option."
+- id: hub_billing_checkout_invoice_vat_id
+ translation: "VAT ID"
+- id: hub_billing_checkout_invoice_vat_id_hint
+ translation: "Required for EU countries outside Germany."
+- id: hub_billing_checkout_invoice_instruction
+ translation: "An invoice will be issued and sent to your email address."
+- id: hub_billing_checkout_invoice_submit
+ translation: "Buy on Invoice"
+
+- id: hub_billing_checkout_success_description
+ translation: "Thank you for your purchase! Your subscription is now active."
+- id: hub_billing_checkout_success_invoice_description
+ translation: "An invoice will be issued and sent to your email address."
+- id: hub_billing_checkout_success_relicense_description
+ translation: "To receive your license, please return to your Hub instance and start the subscription process again."
+
- id: hub_billing_checkout_community_title
translation: "Community"
- id: hub_billing_checkout_community_statement
diff --git a/layouts/hub-billing/single.html b/layouts/hub-billing/single.html
index b237b57d8..25310b201 100644
--- a/layouts/hub-billing/single.html
+++ b/layouts/hub-billing/single.html
@@ -3,7 +3,7 @@
{{ end }}
{{ define "main" }}
@@ -264,31 +220,45 @@
- {{ i18n "hub_billing_manage_change_quantity_confirmation_current_amount_description" . }}:
+ {{ i18n "hub_billing_manage_change_quantity_confirmation_current_amount_description" . }}:
{{ i18n "hub_billing_manage_change_quantity_confirmation_new_amount_description" . }}:
-
-
+
+
+
+
+
+ {{ i18n "hub_billing_manage_change_quantity_confirmation_increase_warning" . }}
+
+
+
+ {{ i18n "hub_billing_manage_next_payment_date_description" . }}:
+
+
+
+
+
+ {{ i18n "hub_billing_manage_change_quantity_confirmation_increase_warning_invoice" . }}
+
+
+
+
- {{ i18n "hub_billing_manage_change_quantity_confirmation_increase_warning" . }}
+ {{ i18n "hub_billing_manage_change_quantity_confirmation_decrease_warning" . }}
-
-
- {{ i18n "hub_billing_manage_modal_charge_amount_description" . }}:
+
+
+ {{ i18n "hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice" . }}
-
-
- {{ i18n "hub_billing_manage_change_quantity_confirmation_decrease_warning" . }}
-
-
@@ -518,6 +605,30 @@
+
+
+
+
+
+
+ {{ i18n "hub_billing_checkout_success_description" . }}
+
+
+
+ {{ i18n "hub_billing_checkout_success_invoice_description" . }}
+
+ {{ partial "hub-license-block.html" . }}
+
+
+
+ {{ i18n "hub_billing_checkout_success_relicense_description" . }}
+
+
+
+
+
{{ end }}
diff --git a/layouts/partials/hub-license-block.html b/layouts/partials/hub-license-block.html
new file mode 100644
index 000000000..48065b19e
--- /dev/null
+++ b/layouts/partials/hub-license-block.html
@@ -0,0 +1,27 @@
+
+
+
+ {{ i18n "hub_billing_manage_license_key_title" . }}
+
+
+ {{ i18n "hub_billing_manage_license_key_instruction" . }}
+
+
+
+ {{ i18n "hub_billing_manage_license_key_retry_action" . }}
+
+
+ {{ i18n "hub_billing_manage_license_key_transfer_action" . }}
+
+
+
+ {{ $challengeUrl := printf "%s/licenses/hub/challenge" .Site.Params.apiBaseUrl }}
+ {{ partial "captcha.html" (dict "challengeUrl" $challengeUrl "captchaPayload" "subscriptionData.captcha" "captchaState" "captchaState" "ref" "refreshCaptcha" "auto" "onload" "onVerified" "hubSubscription.refreshToken()") }}
+
+
+
+
+ {{ i18n "hub_billing_loading_description" . }}
+
+
+
From ce563d4eebd16b6d2e62712380ff3871b3063961 Mon Sep 17 00:00:00 2001
From: Tobias Hagemann
Date: Thu, 11 Jun 2026 12:28:36 +0200
Subject: [PATCH 2/4] Add missing billing service prefix to api endpoint URLs
---
assets/js/hubsubscription.js | 10 +++++-----
layouts/hub-billing/single.html | 6 +++---
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/assets/js/hubsubscription.js b/assets/js/hubsubscription.js
index 771e1a4d1..36c4bb40f 100644
--- a/assets/js/hubsubscription.js
+++ b/assets/js/hubsubscription.js
@@ -1,10 +1,10 @@
"use strict";
-const CARD_CHECKOUT_URL = API_BASE_URL + '/paddle-classic/checkout';
-const INVOICE_CHECKOUT_URL = API_BASE_URL + '/espocrm/checkout';
-const MANAGE_SUBSCRIPTION_BASE_URL = API_BASE_URL + '/manage/subscription';
-const SESSION_REQUEST_URL = API_BASE_URL + '/manage/session/request';
-const SESSION_CONFIRM_URL = API_BASE_URL + '/manage/session/confirm';
+const CARD_CHECKOUT_URL = API_BASE_URL + '/billing/paddle-classic/checkout';
+const INVOICE_CHECKOUT_URL = API_BASE_URL + '/billing/espocrm/checkout';
+const MANAGE_SUBSCRIPTION_BASE_URL = API_BASE_URL + '/billing/manage/subscription';
+const SESSION_REQUEST_URL = API_BASE_URL + '/billing/manage/session/request';
+const SESSION_CONFIRM_URL = API_BASE_URL + '/billing/manage/session/confirm';
const CUSTOM_BILLING_URL = LEGACY_STORE_URL + '/hub/custom-billing';
const REFRESH_LICENSE_URL = API_BASE_URL + '/licenses/hub/refresh';
diff --git a/layouts/hub-billing/single.html b/layouts/hub-billing/single.html
index 25310b201..fdbc3bb55 100644
--- a/layouts/hub-billing/single.html
+++ b/layouts/hub-billing/single.html
@@ -42,7 +42,7 @@
{{ i18n "hub_billing_createsession_submit" . }}
- {{ $challengeUrl := printf "%s/manage/session/challenge" .Site.Params.apiBaseUrl }}
+ {{ $challengeUrl := printf "%s/billing/manage/session/challenge" .Site.Params.apiBaseUrl }}
{{ partial "captcha.html" (dict "challengeUrl" $challengeUrl "captchaPayload" "subscriptionData.captcha" "captchaState" "captchaState") }}
@@ -435,7 +435,7 @@ {{ partial "checkbox.html" (dict "context" . "alpineVariable" "acceptTerms" "label" (i18n "accept_hub_managed_terms_and_privacy" | safeHTML)) }}
{{ partial "checkbox.html" (dict "context" . "alpineVariable" "acceptTerms" "label" (i18n "accept_terms_and_privacy" | safeHTML)) }}
- {{ $cardChallengeUrl := printf "%s/paddle-classic/checkout/challenge" .Site.Params.apiBaseUrl }}
+ {{ $cardChallengeUrl := printf "%s/billing/paddle-classic/checkout/challenge" .Site.Params.apiBaseUrl }}
{{ partial "captcha.html" (dict "challengeUrl" $cardChallengeUrl "captchaPayload" "subscriptionData.cardCaptcha" "captchaState" "captchaState" "ref" "cardCaptcha") }}
@@ -525,7 +525,7 @@ {{ partial "checkbox.html" (dict "context" . "alpineVariable" "acceptTerms" "label" (i18n "accept_hub_managed_terms_and_privacy" | safeHTML)) }}
{{ partial "checkbox.html" (dict "context" . "alpineVariable" "acceptTerms" "label" (i18n "accept_terms_and_privacy" | safeHTML)) }}
- {{ $invoiceChallengeUrl := printf "%s/espocrm/checkout/challenge" .Site.Params.apiBaseUrl }}
+ {{ $invoiceChallengeUrl := printf "%s/billing/espocrm/checkout/challenge" .Site.Params.apiBaseUrl }}
{{ partial "captcha.html" (dict "challengeUrl" $invoiceChallengeUrl "captchaPayload" "subscriptionData.invoiceCaptcha" "captchaState" "captchaState" "ref" "invoiceCaptcha") }}
From 78b57b1be8934731a43aa7245415acff2f984e36 Mon Sep 17 00:00:00 2001
From: Tobias Hagemann
Date: Mon, 6 Jul 2026 18:18:23 +0200
Subject: [PATCH 3/4] Adopt session-first billing flow: customer lookup,
session links, session-authorized manage and checkout
---
assets/js/hubsubscription.js | 214 ++++++++++++++++--------
layouts/hub-billing/single.html | 77 +++++++--
layouts/partials/hub-license-block.html | 10 +-
3 files changed, 207 insertions(+), 94 deletions(-)
diff --git a/assets/js/hubsubscription.js b/assets/js/hubsubscription.js
index 36c4bb40f..dc5772256 100644
--- a/assets/js/hubsubscription.js
+++ b/assets/js/hubsubscription.js
@@ -1,40 +1,43 @@
"use strict";
+const BILLING_SESSION_URL = API_BASE_URL + '/billing/session';
+const BILLING_CUSTOMER_URL = API_BASE_URL + '/billing/customers/by-hub-id';
const CARD_CHECKOUT_URL = API_BASE_URL + '/billing/paddle-classic/checkout';
const INVOICE_CHECKOUT_URL = API_BASE_URL + '/billing/espocrm/checkout';
const MANAGE_SUBSCRIPTION_BASE_URL = API_BASE_URL + '/billing/manage/subscription';
-const SESSION_REQUEST_URL = API_BASE_URL + '/billing/manage/session/request';
-const SESSION_CONFIRM_URL = API_BASE_URL + '/billing/manage/session/confirm';
const CUSTOM_BILLING_URL = LEGACY_STORE_URL + '/hub/custom-billing';
-const REFRESH_LICENSE_URL = API_BASE_URL + '/licenses/hub/refresh';
+const GET_LICENSE_URL = API_BASE_URL + '/licenses/hub';
class HubSubscription {
constructor(form, subscriptionData, searchParams) {
this._form = form;
this._subscriptionData = subscriptionData;
- let fragmentParams = new URLSearchParams(location.hash.substring(1));
- this._subscriptionData.oldLicense = fragmentParams.get('oldLicense');
+ this._subscriptionData.oldLicense = searchParams.get('oldLicense');
if (this._subscriptionData.oldLicense) {
this._subscriptionData.hubId = this.extractHubId(this._subscriptionData.oldLicense);
if (!this._subscriptionData.hubId) {
this._subscriptionData.oldLicense = null;
}
}
- this._subscriptionData.hubId = this._subscriptionData.hubId ?? fragmentParams.get('hub_id') ?? searchParams.get('hub_id');
- let returnUrl = fragmentParams.get('returnUrl') ?? searchParams.get('return_url');
+ this._subscriptionData.hubId = this._subscriptionData.hubId ?? searchParams.get('hub_id');
+ let returnUrl = searchParams.get('return_url');
if (returnUrl) {
this._subscriptionData.returnUrl = returnUrl;
}
- let nonce = fragmentParams.get('nonce');
- if (nonce) {
- this.restoreHubContext();
- history.replaceState(null, '', location.pathname + location.search);
+ // Capture the Hub's `token_transfer` value (how the license should be delivered) so it can be stored in
+ // the billing session.
+ this._subscriptionData.tokenTransfer = searchParams.get('token_transfer') ?? 'queryParam';
+ this._subscriptionData.session = searchParams.get('session');
+ if (this._subscriptionData.session) {
+ // We returned from the confirmation link (/hub/billing?session=): resolve the verified
+ // billing session and continue into the manage or checkout flow.
this._subscriptionData.state = 'LOADING';
- this.confirmSession(nonce);
+ this.loadBillingSession();
} else if (this._subscriptionData.hubId && this._subscriptionData.hubId.length > 0 && this._subscriptionData.returnUrl && this._subscriptionData.returnUrl.length > 0) {
- this._subscriptionData.state = 'LOADING';
- this.loadCheckoutPrerequisites();
+ // Opened from the Hub without a verified session yet: ask the customer to request a
+ // confirmation link before we can manage their subscription or check out.
+ this._subscriptionData.state = 'CREATE_SESSION';
}
this._paddle = $.ajax({
url: 'https://cdn.paddle.com/paddle/paddle.js',
@@ -60,19 +63,7 @@ class HubSubscription {
}
authHeaders() {
- return { Authorization: 'Bearer ' + this._subscriptionData.sessionToken };
- }
-
- restoreHubContext() {
- let hubId = this._subscriptionData.hubId;
- let oldLicense = sessionStorage.getItem(`hubOldLicense:${hubId}`);
- if (oldLicense) {
- this._subscriptionData.oldLicense = oldLicense;
- }
- let returnUrl = sessionStorage.getItem(`hubReturnUrl:${hubId}`);
- if (returnUrl) {
- this._subscriptionData.returnUrl = returnUrl;
- }
+ return { Authorization: 'Bearer ' + this._subscriptionData.session };
}
loadCheckoutPrerequisites() {
@@ -116,7 +107,7 @@ class HubSubscription {
this._subscriptionData.state = 'EXISTING_CUSTOMER';
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
- this._subscriptionData.needsTokenRefresh = !!this._subscriptionData.oldLicense;
+ this.refreshToken();
}
onLoadSubscriptionFailed(status, error) {
@@ -136,68 +127,130 @@ class HubSubscription {
this._subscriptionData.inProgress = false;
}
- createSession() {
- if (!$(this._form)[0].checkValidity()) {
- $(this._form).find(':input').addClass('show-invalid');
- this._subscriptionData.errorMessage = 'Please fill in all required fields.';
- return;
- }
+ loadBillingSession() {
+ this._subscriptionData.inProgress = true;
+ this._subscriptionData.errorMessage = '';
+ $.ajax({
+ url: BILLING_SESSION_URL + '/' + encodeURIComponent(this._subscriptionData.session),
+ type: 'GET'
+ }).done(data => {
+ this.onLoadBillingSessionSucceeded(data);
+ }).fail(xhr => {
+ this.onLoadBillingSessionFailed(xhr.status, xhr.responseJSON?.message || 'Loading billing session failed.');
+ });
+ }
- let hubId = this._subscriptionData.hubId;
- if (this._subscriptionData.oldLicense) {
- sessionStorage.setItem(`hubOldLicense:${hubId}`, this._subscriptionData.oldLicense);
+ onLoadBillingSessionSucceeded(data) {
+ this._subscriptionData.hubId = data.hubId;
+ this._subscriptionData.email = data.email;
+ this._subscriptionData.returnUrl = data.returnUrl;
+ this._subscriptionData.tokenTransfer = data.tokenTransfer;
+ if (!this._subscriptionData.invoice.contact_email) {
+ this._subscriptionData.invoice.contact_email = data.email;
+ }
+ this._subscriptionData.errorMessage = '';
+ // The session is verified; a session already linked to a billing manages it (the manage endpoints
+ // only accept linked sessions), an unlinked one belongs to a new customer heading into checkout.
+ if (data.billingId) {
+ this.loadManageSubscription();
+ } else {
+ this.loadCheckoutPrerequisites();
}
- if (this._subscriptionData.returnUrl) {
- sessionStorage.setItem(`hubReturnUrl:${hubId}`, this._subscriptionData.returnUrl);
+ }
+
+ onLoadBillingSessionFailed(status, error) {
+ if (status == 404) {
+ this._subscriptionData.state = 'LINK_EXPIRED';
+ this._subscriptionData.errorMessage = '';
+ } else if (this._subscriptionData.hubId) {
+ this._subscriptionData.state = 'CREATE_SESSION';
+ this._subscriptionData.errorMessage = error;
+ } else {
+ this._subscriptionData.state = 'MISSING_PARAMS';
+ this._subscriptionData.errorMessage = '';
}
+ this._subscriptionData.inProgress = false;
+ }
+
+ lookupCustomer() {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
+ // First challenge (from /billing/customers/challenge) gates this lookup: it tells us whether
+ // the Hub is already linked to a customer before we ask for a confirmation link.
$.ajax({
- url: SESSION_REQUEST_URL,
- type: 'POST',
+ url: BILLING_CUSTOMER_URL + '/' + encodeURIComponent(this._subscriptionData.hubId),
+ type: 'GET',
data: {
- captcha: this._subscriptionData.captcha,
- hub_id: this._subscriptionData.hubId
+ captcha: this._subscriptionData.captcha
}
- }).done(_ => {
- this.onCreateSessionSucceeded();
+ }).done(data => {
+ this.onLookupCustomerSucceeded(data);
}).fail(xhr => {
- this.onCreateSessionFailed(xhr.responseJSON?.message || 'Requesting access failed.');
+ this.onLookupCustomerFailed(xhr.status, xhr.responseJSON?.message || 'Looking up your subscription failed.');
});
}
- onCreateSessionSucceeded() {
- this._subscriptionData.state = 'CREATE_SESSION_SUCCESS';
- this._subscriptionData.inProgress = false;
+ onLookupCustomerSucceeded(data) {
+ // The Hub is already linked to a customer: the API returns their redacted email so we can
+ // show where the confirmation link will be sent without revealing the full address.
+ this._subscriptionData.redactedEmail = data.email;
+ this._subscriptionData.needsEmail = false;
+ this._subscriptionData.lookupDone = true;
this._subscriptionData.errorMessage = '';
+ this._subscriptionData.inProgress = false;
}
- onCreateSessionFailed(error) {
+ onLookupCustomerFailed(status, error) {
+ // 404 means the Hub is not linked to a customer yet: ask for the purchase email. Any other
+ // failure falls back to the same manual entry (the lookup captcha solves only once, so a
+ // transient failure cannot re-trigger the lookup, and for a known hub the server ignores the
+ // entered address and mails the one on file) — but keeps the error visible.
this._subscriptionData.inProgress = false;
- this._subscriptionData.errorMessage = error;
+ this._subscriptionData.needsEmail = true;
+ this._subscriptionData.redactedEmail = null;
+ this._subscriptionData.lookupDone = true;
+ this._subscriptionData.errorMessage = status == 404 ? '' : error;
}
- confirmSession(nonce) {
+ createSession() {
+ if (!$(this._form)[0].checkValidity()) {
+ $(this._form).find(':input').addClass('show-invalid');
+ this._subscriptionData.errorMessage = 'Please fill in all required fields.';
+ return;
+ }
+
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
+ let body = {
+ hubId: this._subscriptionData.hubId,
+ returnUrl: this._subscriptionData.returnUrl,
+ tokenTransfer: this._subscriptionData.tokenTransfer,
+ captcha: this._subscriptionData.captcha
+ };
+ if (this._subscriptionData.email) {
+ body.email = this._subscriptionData.email;
+ }
$.ajax({
- url: SESSION_CONFIRM_URL,
+ url: BILLING_SESSION_URL,
type: 'POST',
- data: {
- nonce: nonce
- }
- }).done(data => {
- this._subscriptionData.sessionToken = data.token;
- this.loadManageSubscription();
+ contentType: 'application/json',
+ data: JSON.stringify(body)
+ }).done(_ => {
+ this.onCreateSessionSucceeded();
}).fail(xhr => {
- this.onConfirmSessionFailed(xhr.responseJSON?.message || 'Confirming access failed.');
+ this.onCreateSessionFailed(xhr.responseJSON?.message || 'Requesting confirmation link failed.');
});
}
- onConfirmSessionFailed(error) {
- this._subscriptionData.state = 'CREATE_SESSION';
- this._subscriptionData.errorMessage = error;
+ onCreateSessionSucceeded() {
+ this._subscriptionData.state = 'CREATE_SESSION_SUCCESS';
this._subscriptionData.inProgress = false;
+ this._subscriptionData.errorMessage = '';
+ }
+
+ onCreateSessionFailed(error) {
+ this._subscriptionData.inProgress = false;
+ this._subscriptionData.errorMessage = error;
}
loadCustomBilling(continueHandler) {
@@ -339,7 +392,8 @@ class HubSubscription {
captcha: this._subscriptionData.cardCaptcha,
hub_id: this._subscriptionData.hubId,
product_id: productId,
- quantity: this._subscriptionData.quantity
+ quantity: this._subscriptionData.quantity,
+ session: this._subscriptionData.session
}
}).done(data => {
this.openPaddleCheckout(data.pay_link, locale);
@@ -354,7 +408,7 @@ class HubSubscription {
override: payLink,
email: this._subscriptionData.email,
locale: locale,
- passthrough: JSON.stringify({ hub_id: this._subscriptionData.hubId }),
+ passthrough: JSON.stringify({ hub_id: this._subscriptionData.hubId, session: this._subscriptionData.session }),
successCallback: data => this.getPaddleOrderDetails(data.checkout.id),
closeCallback: () => {
this._subscriptionData.inProgress = false;
@@ -394,6 +448,7 @@ class HubSubscription {
hub_id: this._subscriptionData.hubId,
product_id: this.selectedPlanId(),
quantity: this._subscriptionData.quantity,
+ session: this._subscriptionData.session,
account_name: invoice.account_name,
vat_id: invoice.vat_id,
address_street: invoice.address_street,
@@ -415,10 +470,8 @@ class HubSubscription {
this._subscriptionData.state = 'CHECKOUT_SUCCESS';
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
- if (this._subscriptionData.oldLicense) {
- this._subscriptionData.needsTokenRefresh = true;
- this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl;
- }
+ this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl;
+ this.refreshToken();
}
onPostFailed(error) {
@@ -429,6 +482,7 @@ class HubSubscription {
updatePaymentMethod(locale) {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
+ this._subscriptionData.shouldTransferToHub = false;
$.ajax({
url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/payment-method`,
type: 'GET',
@@ -452,6 +506,8 @@ class HubSubscription {
pause() {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
+ // a stale transfer intent from an earlier action must not redirect after this refresh
+ this._subscriptionData.shouldTransferToHub = false;
$.ajax({
url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/pause`,
type: 'POST',
@@ -550,14 +606,15 @@ class HubSubscription {
}
refreshToken() {
+ this._subscriptionData.needsTokenRefresh = true;
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
- url: REFRESH_LICENSE_URL,
- type: 'POST',
+ url: GET_LICENSE_URL,
+ type: 'GET',
data: {
- token: this._subscriptionData.oldLicense,
- captcha: this._subscriptionData.captcha
+ session: this._subscriptionData.session,
+ legacy: this._subscriptionData.tokenTransfer === 'queryParam'
}
}).done(token => {
this._subscriptionData.token = token;
@@ -568,6 +625,8 @@ class HubSubscription {
this.transferTokenToHub();
}
}).fail(xhr => {
+ // Expected for a card checkout until Paddle's payment webhook links the session to the new
+ // billing; the license block then offers a retry.
this._subscriptionData.errorMessage = xhr.responseJSON?.message || 'Refreshing license failed.';
this._subscriptionData.needsTokenRefresh = false;
this._subscriptionData.inProgress = false;
@@ -575,7 +634,14 @@ class HubSubscription {
}
transferTokenToHub() {
- window.open(this._subscriptionData.returnUrl + '?token=' + this._subscriptionData.token, '_self');
+ if (this._subscriptionData.tokenTransfer === 'queryParam') {
+ location.href = this._subscriptionData.returnUrl + '?token=' + encodeURIComponent(this._subscriptionData.token);
+ } else if (this._subscriptionData.tokenTransfer === 'session') {
+ // Hand the Hub the billing session id instead; it resolves the license itself.
+ location.href = this._subscriptionData.returnUrl + '?session=' + encodeURIComponent(this._subscriptionData.session);
+ } else {
+ console.error('Unknown token transfer method:', this._subscriptionData.tokenTransfer);
+ }
}
}
diff --git a/layouts/hub-billing/single.html b/layouts/hub-billing/single.html
index fdbc3bb55..af67bf8ae 100644
--- a/layouts/hub-billing/single.html
+++ b/layouts/hub-billing/single.html
@@ -3,7 +3,7 @@
{{ end }}
{{ define "main" }}
-
+
+
+
+ {{ i18n "hub_billing_generic_title" . }}
+
+
+
+ {{ i18n "hub_billing_linkexpired_description" . }}
+
+
+
+
diff --git a/layouts/partials/hub-license-block.html b/layouts/partials/hub-license-block.html
index 48065b19e..1fe57395f 100644
--- a/layouts/partials/hub-license-block.html
+++ b/layouts/partials/hub-license-block.html
@@ -1,4 +1,4 @@
-
+
{{ i18n "hub_billing_manage_license_key_title" . }}
@@ -7,18 +7,12 @@
{{ i18n "hub_billing_manage_license_key_instruction" . }}
-
+
{{ i18n "hub_billing_manage_license_key_retry_action" . }}
{{ i18n "hub_billing_manage_license_key_transfer_action" . }}
-
-
- {{ $challengeUrl := printf "%s/licenses/hub/challenge" .Site.Params.apiBaseUrl }}
- {{ partial "captcha.html" (dict "challengeUrl" $challengeUrl "captchaPayload" "subscriptionData.captcha" "captchaState" "captchaState" "ref" "refreshCaptcha" "auto" "onload" "onVerified" "hubSubscription.refreshToken()") }}
-
-
{{ i18n "hub_billing_loading_description" . }}
From f722818ea7585051674ac6021edef3e9afc42668 Mon Sep 17 00:00:00 2001
From: Tobias Hagemann
Date: Tue, 7 Jul 2026 20:29:47 +0200
Subject: [PATCH 4/4] Add invoice order summary with exact EspoCRM pricing and
prorated seat-change preview
---
assets/js/const.template.js | 2 +
assets/js/hubsubscription.js | 37 +++++++++++-
config/development/params.yaml | 4 ++
config/production/params.yaml | 4 ++
config/staging/params.yaml | 4 ++
i18n/de.yaml | 22 +++++++
i18n/en.yaml | 22 +++++++
layouts/hub-billing/single.html | 100 ++++++++++++++++++++++++++++++--
8 files changed, 187 insertions(+), 8 deletions(-)
diff --git a/assets/js/const.template.js b/assets/js/const.template.js
index f845d1351..32725b6df 100644
--- a/assets/js/const.template.js
+++ b/assets/js/const.template.js
@@ -13,6 +13,8 @@ const PADDLE_HUB_MANAGED_MONTHLY_PLAN_ID = {{ .Site.Params.paddleHubManagedMonth
const PADDLE_PRICES_URL = '{{ .Site.Params.paddlePricesUrl }}';
const PADDLE_DESKTOP_SALE_PRICE_ID = '{{ .Site.Params.paddleDesktopSalePriceId }}';
const PADDLE_ANDROID_SALE_PRICE_ID = '{{ .Site.Params.paddleAndroidSalePriceId }}';
+const ESPOCRM_HUB_SELF_HOSTED_PRODUCT_ID = '{{ .Site.Params.espocrmHubSelfHostedProductId }}';
+const ESPOCRM_HUB_MANAGED_PRODUCT_ID = '{{ .Site.Params.espocrmHubManagedProductId }}';
const LEGACY_STORE_URL = '{{ .Site.Params.legacyStoreUrl }}';
const HUB_MANAGED_DOMAIN = '{{ .Site.Params.hubManagedDomain }}';
const STRIPE_PK = '{{ .Site.Params.stripePk }}';
diff --git a/assets/js/hubsubscription.js b/assets/js/hubsubscription.js
index dc5772256..fd9351ad0 100644
--- a/assets/js/hubsubscription.js
+++ b/assets/js/hubsubscription.js
@@ -4,6 +4,7 @@ const BILLING_SESSION_URL = API_BASE_URL + '/billing/session';
const BILLING_CUSTOMER_URL = API_BASE_URL + '/billing/customers/by-hub-id';
const CARD_CHECKOUT_URL = API_BASE_URL + '/billing/paddle-classic/checkout';
const INVOICE_CHECKOUT_URL = API_BASE_URL + '/billing/espocrm/checkout';
+const INVOICE_PRICE_URL = API_BASE_URL + '/billing/espocrm/checkout/price';
const MANAGE_SUBSCRIPTION_BASE_URL = API_BASE_URL + '/billing/manage/subscription';
const CUSTOM_BILLING_URL = LEGACY_STORE_URL + '/hub/custom-billing';
const GET_LICENSE_URL = API_BASE_URL + '/licenses/hub';
@@ -430,6 +431,35 @@ class HubSubscription {
});
}
+ invoiceProductId() {
+ return this._subscriptionData.customBilling?.managed ? ESPOCRM_HUB_MANAGED_PRODUCT_ID : ESPOCRM_HUB_SELF_HOSTED_PRODUCT_ID;
+ }
+
+ loadInvoicePrice() {
+ if (this._subscriptionData.invoicePrice || !this.invoiceProductId()) {
+ return;
+ }
+ $.ajax({
+ url: INVOICE_PRICE_URL,
+ type: 'GET',
+ data: {
+ product_id: this.invoiceProductId()
+ }
+ }).done(data => {
+ this._subscriptionData.invoicePrice = data;
+ });
+ }
+
+ askForInvoiceConfirmation() {
+ if (!$(this._form)[0].checkValidity()) {
+ $(this._form).find(':input').addClass('show-invalid');
+ this._subscriptionData.errorMessage = 'Please fill in all required fields.';
+ return;
+ }
+ this._subscriptionData.errorMessage = '';
+ this._subscriptionData.invoiceConfirmModal.open = true;
+ }
+
invoiceCheckout() {
if (!$(this._form)[0].checkValidity()) {
$(this._form).find(':input').addClass('show-invalid');
@@ -446,7 +476,7 @@ class HubSubscription {
data: {
captcha: this._subscriptionData.invoiceCaptcha,
hub_id: this._subscriptionData.hubId,
- product_id: this.selectedPlanId(),
+ product_id: this.invoiceProductId(),
quantity: this._subscriptionData.quantity,
session: this._subscriptionData.session,
account_name: invoice.account_name,
@@ -468,6 +498,7 @@ class HubSubscription {
onCheckoutSucceeded() {
this._subscriptionData.state = 'CHECKOUT_SUCCESS';
+ this._subscriptionData.invoiceConfirmModal.open = false;
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl;
@@ -542,6 +573,7 @@ class HubSubscription {
openChangeSeatsModal() {
this._subscriptionData.quantity = this._subscriptionData.details.seats;
this._subscriptionData.changeSeatsModal.nextPayment = null;
+ this._subscriptionData.changeSeatsModal.invoicePreview = null;
this._subscriptionData.changeSeatsModal.confirmation = false;
this._subscriptionData.changeSeatsModal.open = true;
}
@@ -554,7 +586,7 @@ class HubSubscription {
}
this._subscriptionData.changeSeatsModal.confirmation = true;
- if (this._subscriptionData.details.processor == 'PADDLE_CLASSIC') {
+ if (this._subscriptionData.details.processor == 'PADDLE_CLASSIC' || this._subscriptionData.details.processor == 'ESPOCRM') {
this.previewChangeQuantity();
}
}
@@ -571,6 +603,7 @@ class HubSubscription {
}
}).done(data => {
this._subscriptionData.changeSeatsModal.nextPayment = data.next_payment;
+ this._subscriptionData.changeSeatsModal.invoicePreview = data.prorated_amount != null ? data : null;
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
}).fail(xhr => {
diff --git a/config/development/params.yaml b/config/development/params.yaml
index b616dd930..5bd6cf92b 100644
--- a/config/development/params.yaml
+++ b/config/development/params.yaml
@@ -23,5 +23,9 @@ paddlePricesUrl: https://sandbox-checkout.paddle.com/api/2.0/prices
paddleDesktopSalePriceId: pri_01kk1z8f8cb5f6fnf9x7bv4xg9
paddleAndroidSalePriceId: pri_01kk243hedea8mbabs8q22ygdn
+# ESPOCRM
+espocrmHubSelfHostedProductId: 69bd302d5c65eabaa
+espocrmHubManagedProductId: 69bd302d521a70103
+
# STRIPE
stripePk: pk_test_51RCM24IBZmkR4F9UiLBiSmsAnJvWqmHcDLxXR8ABKK1MNsZk3zCk2VJW7ZfaBlD81zpQxCX243sS3LEp9dABwiG800kJnGykDF
diff --git a/config/production/params.yaml b/config/production/params.yaml
index a7b353c00..42f477823 100644
--- a/config/production/params.yaml
+++ b/config/production/params.yaml
@@ -23,5 +23,9 @@ paddlePricesUrl: https://checkout.paddle.com/api/2.0/prices
paddleDesktopSalePriceId: pri_01kk4gejs06jg0m0n9tghk0ktr
paddleAndroidSalePriceId: pri_01kk4gg6c3pj0tq0vvpf4x4vw2
+# ESPOCRM
+espocrmHubSelfHostedProductId: # TODO: set production EspoCRM Self-Hosted Standard product id
+espocrmHubManagedProductId: # TODO: set production EspoCRM Managed Standard product id
+
# STRIPE
stripePk: pk_live_eSasX216vGvC26GdbVwA011V
diff --git a/config/staging/params.yaml b/config/staging/params.yaml
index fa0b63b11..8be0ea28b 100644
--- a/config/staging/params.yaml
+++ b/config/staging/params.yaml
@@ -23,5 +23,9 @@ paddlePricesUrl: https://sandbox-checkout.paddle.com/api/2.0/prices
paddleDesktopSalePriceId: pri_01kk1z8f8cb5f6fnf9x7bv4xg9
paddleAndroidSalePriceId: pri_01kk243hedea8mbabs8q22ygdn
+# ESPOCRM
+espocrmHubSelfHostedProductId: 69bd302d5c65eabaa
+espocrmHubManagedProductId: 69bd302d521a70103
+
# STRIPE
stripePk: pk_test_51RCM24IBZmkR4F9UiLBiSmsAnJvWqmHcDLxXR8ABKK1MNsZk3zCk2VJW7ZfaBlD81zpQxCX243sS3LEp9dABwiG800kJnGykDF
diff --git a/i18n/de.yaml b/i18n/de.yaml
index f4e409d7c..3bca13188 100644
--- a/i18n/de.yaml
+++ b/i18n/de.yaml
@@ -588,6 +588,12 @@
translation: "Du bist dabei, das Sitzplatzlimit zu verringern. Wenn du dies bestätigst, wird deine nächste Zahlung um die Differenz reduziert."
- id: hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice
translation: "Du bist dabei, das Sitzplatzlimit zu verringern. Wenn du dies bestätigst, wird die Reduzierung auf deiner nächsten Rechnung berücksichtigt."
+- id: hub_billing_manage_change_quantity_confirmation_prorated_invoice
+ translation: "Anteiliger Betrag für den laufenden Zeitraum (wird jetzt in Rechnung gestellt)"
+- id: hub_billing_manage_change_quantity_confirmation_prorated_credit_invoice
+ translation: "Anteilige Gutschrift für den laufenden Zeitraum"
+- id: hub_billing_manage_change_quantity_confirmation_new_recurring_invoice
+ translation: "Neuer Jahresbetrag (netto)"
- id: hub_billing_checkout_description
translation: "Schöpfe das volle Potenzial deiner Hub-Instanz aus und hol dein Team mit der clientseitigen Verschlüsselung für deinen Cloud-Speicher an Bord."
@@ -665,6 +671,22 @@
translation: "Eine Rechnung wird ausgestellt und an deine E-Mail-Adresse gesendet."
- id: hub_billing_checkout_invoice_submit
translation: "Auf Rechnung kaufen"
+- id: hub_billing_checkout_invoice_total
+ translation: "Gesamtbetrag"
+- id: hub_billing_checkout_invoice_total_suffix
+ translation: "pro Jahr (netto)"
+- id: hub_billing_checkout_invoice_total_vat_hint
+ translation: "zzgl. 19 % USt."
+- id: hub_billing_checkout_invoice_total_reverse_charge_hint
+ translation: "Reverse-Charge-Verfahren – Steuerschuldnerschaft des Leistungsempfängers"
+- id: hub_billing_checkout_invoice_confirm_title
+ translation: "Bestellübersicht"
+- id: hub_billing_checkout_invoice_confirm_product
+ translation: "Produkt"
+- id: hub_billing_checkout_invoice_confirm_unit_price
+ translation: "Preis pro Seat"
+- id: hub_billing_checkout_invoice_confirm_billing_address
+ translation: "Rechnungsadresse"
- id: hub_billing_checkout_success_description
translation: "Vielen Dank für deinen Kauf! Dein Abonnement ist jetzt aktiv."
diff --git a/i18n/en.yaml b/i18n/en.yaml
index 8945677e5..e0f953513 100644
--- a/i18n/en.yaml
+++ b/i18n/en.yaml
@@ -588,6 +588,12 @@
translation: "You are about to decrease the seats limit. By confirming, your next payment will be reduced by the difference."
- id: hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice
translation: "You are about to decrease the seats limit. By confirming, the reduction will be reflected on your next invoice."
+- id: hub_billing_manage_change_quantity_confirmation_prorated_invoice
+ translation: "Prorated amount for the current period (invoiced now)"
+- id: hub_billing_manage_change_quantity_confirmation_prorated_credit_invoice
+ translation: "Prorated credit for the current period"
+- id: hub_billing_manage_change_quantity_confirmation_new_recurring_invoice
+ translation: "New yearly total (net)"
- id: hub_billing_checkout_description
translation: "Unlock the full potential of your Hub instance and get your team on board with client-side encryption for your cloud storage."
@@ -665,6 +671,22 @@
translation: "An invoice will be issued and sent to your email address."
- id: hub_billing_checkout_invoice_submit
translation: "Buy on Invoice"
+- id: hub_billing_checkout_invoice_total
+ translation: "Total"
+- id: hub_billing_checkout_invoice_total_suffix
+ translation: "per year (net)"
+- id: hub_billing_checkout_invoice_total_vat_hint
+ translation: "plus 19% German VAT"
+- id: hub_billing_checkout_invoice_total_reverse_charge_hint
+ translation: "reverse charge – VAT to be accounted for by the recipient"
+- id: hub_billing_checkout_invoice_confirm_title
+ translation: "Order Summary"
+- id: hub_billing_checkout_invoice_confirm_product
+ translation: "Product"
+- id: hub_billing_checkout_invoice_confirm_unit_price
+ translation: "Price per Seat"
+- id: hub_billing_checkout_invoice_confirm_billing_address
+ translation: "Billing Address"
- id: hub_billing_checkout_success_description
translation: "Thank you for your purchase! Your subscription is now active."
diff --git a/layouts/hub-billing/single.html b/layouts/hub-billing/single.html
index af67bf8ae..851792f93 100644
--- a/layouts/hub-billing/single.html
+++ b/layouts/hub-billing/single.html
@@ -3,7 +3,7 @@
{{ end }}
{{ define "main" }}
-
@@ -333,6 +347,71 @@
+
+
+
+
+
+
+
+
+
+
+ {{ i18n "hub_billing_checkout_invoice_confirm_title" . }}
+
+
+ {{ i18n "hub_billing_checkout_invoice_confirm_product" . }}:
+
+
+ {{ i18n "hub_billing_manage_status_quantity_description" . }}:
+
+
+ {{ i18n "hub_billing_checkout_invoice_confirm_unit_price" . }}:
+
+
+ {{ i18n "hub_billing_checkout_invoice_total" . }}:
+ {{ i18n "hub_billing_checkout_invoice_total_suffix" . }}
+ {{ i18n "hub_billing_checkout_invoice_total_vat_hint" . }}
+ {{ i18n "hub_billing_checkout_invoice_total_reverse_charge_hint" . }}
+
+
+ {{ i18n "hub_billing_checkout_invoice_confirm_billing_address" . }}:
+
+
+
+
+
+
+
+
+ {{ i18n "hub_billing_checkout_invoice_contact_email" . }}:
+
+
+
+ {{ i18n "hub_billing_checkout_invoice_instruction" . }}
+
+
+
+
+
+
+
+ {{ i18n "hub_billing_checkout_invoice_submit" . }}
+
+
+ {{ i18n "modal_cancel" . }}
+
+
+
+
+
+
+