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 b5a3dd6a7..9a6b7ac4a 100644 --- a/assets/js/hubsubscription.js +++ b/assets/js/hubsubscription.js @@ -2,10 +2,11 @@ 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 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 GET_LICENSE_URL = API_BASE_URL + '/licenses/hub'; class HubSubscription { @@ -15,11 +16,8 @@ class HubSubscription { this._subscriptionData = subscriptionData; this._subscriptionData.oldLicense = searchParams.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; } } @@ -29,17 +27,19 @@ class HubSubscription { this._subscriptionData.returnUrl = returnUrl; } // Capture the Hub's `token_transfer` value (how the license should be delivered) so it can be stored in - // the billing session; default to delivering it as a query parameter. + // the billing session. this._subscriptionData.tokenTransfer = searchParams.get('token_transfer') ?? 'queryParam'; this._subscriptionData.session = searchParams.get('session'); + this._invoicePriceRequestId = 0; + this._awaitingInvoiceCaptcha = false; if (this._subscriptionData.session) { // We returned from the confirmation link (/hub/billing?session=): resolve the verified - // billing session and continue into the existing subscription flow. + // billing session and continue into the manage or checkout flow. this._subscriptionData.state = 'LOADING'; this.loadBillingSession(); } else if (this._subscriptionData.hubId && this._subscriptionData.hubId.length > 0 && this._subscriptionData.returnUrl && this._subscriptionData.returnUrl.length > 0) { // Opened from the Hub without a verified session yet: ask the customer to request a - // confirmation link before we can manage their subscription. + // confirmation link before we can manage their subscription or check out. this._subscriptionData.state = 'CREATE_SESSION'; } this._paddle = $.ajax({ @@ -55,46 +55,74 @@ 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.session }; + } + + 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.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.refreshToken(); } 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; @@ -120,18 +148,29 @@ class HubSubscription { 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; hand off to the existing subscription flow (store + Paddle). - this.loadSubscription(); + // 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(); + } } onLoadBillingSessionFailed(status, error) { if (status == 404) { this._subscriptionData.state = 'LINK_EXPIRED'; this._subscriptionData.errorMessage = ''; - } else { + } 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; } @@ -142,8 +181,11 @@ class HubSubscription { // 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: BILLING_CUSTOMER_URL + '/' + encodeURIComponent(this._subscriptionData.hubId) + '?captcha=' + encodeURIComponent(this._subscriptionData.captcha), - type: 'GET' + url: BILLING_CUSTOMER_URL + '/' + encodeURIComponent(this._subscriptionData.hubId), + type: 'GET', + data: { + captcha: this._subscriptionData.captcha + } }).done(data => { this.onLookupCustomerSucceeded(data); }).fail(xhr => { @@ -162,23 +204,20 @@ class HubSubscription { } 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; - if (status == 404) { - // The Hub is not linked to a customer yet: ask for the purchase email so the session - // request can be created for that address. - this._subscriptionData.needsEmail = true; - this._subscriptionData.redactedEmail = null; - this._subscriptionData.lookupDone = true; - this._subscriptionData.errorMessage = ''; - } else { - this._subscriptionData.errorMessage = error; - } + this._subscriptionData.needsEmail = true; + this._subscriptionData.redactedEmail = null; + this._subscriptionData.lookupDone = true; + this._subscriptionData.errorMessage = status == 404 ? '' : error; } createSession() { if (!$(this._form)[0].checkValidity()) { - $(this._form).find(':input').addClass('show-invalid'); - this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + this.showInvalidFields(); return; } @@ -188,6 +227,7 @@ class HubSubscription { hubId: this._subscriptionData.hubId, returnUrl: this._subscriptionData.returnUrl, tokenTransfer: this._subscriptionData.tokenTransfer, + verificationLinkTarget: 'billing', captcha: this._subscriptionData.captcha }; if (this._subscriptionData.email) { @@ -227,11 +267,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') { @@ -329,39 +365,45 @@ 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'); - this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + this.showInvalidFields(); return; } 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); + // refresh the card captcha for a potential retry; it is the only altcha element rendered + // while the card method is selected + this._form.querySelector('altcha-widget')?.reset(); } 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 + quantity: this._subscriptionData.quantity, + session: this._subscriptionData.session } }).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.'); }); } @@ -385,7 +427,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.'; } @@ -393,29 +435,155 @@ class HubSubscription { }); } - post(subscriptionId) { + invoiceProductId() { + return this._subscriptionData.customBilling?.managed ? ESPOCRM_HUB_MANAGED_PRODUCT_ID : ESPOCRM_HUB_SELF_HOSTED_PRODUCT_ID; + } + + loadInvoicePrice() { + if (!this.invoiceProductId()) { + return; + } + // Keep the previous price visible (dimmed) while reloading, so the summary doesn't jump on seat changes. + this._subscriptionData.invoicePriceLoading = true; + this._subscriptionData.invoicePriceError = false; + // Stale-response guard: only the latest request may populate the summary after rapid seat changes. + let requestId = ++this._invoicePriceRequestId; $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, + url: INVOICE_PRICE_URL, + type: 'GET', + data: { + hub_id: this._subscriptionData.hubId, + product_id: this.invoiceProductId(), + quantity: this._subscriptionData.quantity, + session: this._subscriptionData.session + } + }).done(data => { + if (requestId === this._invoicePriceRequestId) { + this._subscriptionData.invoicePrice = data; + this._subscriptionData.invoicePriceLoading = false; + } + }).fail(_ => { + if (requestId === this._invoicePriceRequestId) { + this._subscriptionData.invoicePrice = null; + this._subscriptionData.invoicePriceError = true; + this._subscriptionData.invoicePriceLoading = false; + } + }); + } + + invoiceQuantityMin() { + return this._subscriptionData.customBilling?.quantity_min || 1; + } + + invoiceQuantityMax() { + return this._subscriptionData.customBilling?.quantity_max || 10000; + } + + setInvoiceQuantity(quantity) { + this._subscriptionData.quantity = Math.min(this.invoiceQuantityMax(), Math.max(this.invoiceQuantityMin(), quantity || this.invoiceQuantityMin())); + this.loadInvoicePrice(); + } + + changeInvoiceQuantity(delta) { + this.setInvoiceQuantity(parseInt(this._subscriptionData.quantity, 10) + delta); + } + + openInvoiceCheckoutModal() { + if (!this.invoiceFieldsValid()) { + this.showInvalidFields(); + return; + } + this._subscriptionData.errorMessage = ''; + this._awaitingInvoiceCaptcha = false; + this._subscriptionData.invoiceCheckoutModal.open = true; + this.loadInvoicePrice(); + } + + showInvalidFields() { + $(this._form).find(':input').addClass('show-invalid'); + this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + } + + // Excludes the altcha widget's internal checkbox, whose checked state is mid-flight while the + // buy button re-solves a challenge. + invoiceFieldsValid() { + return $(this._form).find(':input').toArray().filter(el => !el.closest('altcha-widget')).every(el => el.checkValidity()); + } + + startInvoiceCheckout() { + if (!this.invoiceFieldsValid()) { + this.showInvalidFields(); + return; + } + // Challenges expire server-side within a minute, so solve one fresh at buy time; the widget's + // verified event then triggers onInvoiceCaptchaVerified() with the new payload. The modal's + // widget is the only altcha element rendered while the invoice method is selected. + this._subscriptionData.inProgress = true; + this._subscriptionData.errorMessage = ''; + this._awaitingInvoiceCaptcha = true; + let captchaWidget = this._form.querySelector('altcha-widget'); + captchaWidget?.reset(); + captchaWidget?.verify(); + } + + // One-shot: altcha's reset() does not abort an in-flight solve, so a stale solve and the fresh + // one can both fire a verified event — only the first may trigger the checkout, and only while + // the modal is still open (a solve settling after cancel must not buy anything). + onInvoiceCaptchaVerified() { + if (!this._subscriptionData.invoiceCheckoutModal.open) { + this._awaitingInvoiceCaptcha = false; + return; + } + if (!this._awaitingInvoiceCaptcha) { + return; + } + this._awaitingInvoiceCaptcha = false; + this.invoiceCheckout(); + } + + invoiceCheckout() { + if (!this.invoiceFieldsValid()) { + this.showInvalidFields(); + this._subscriptionData.inProgress = false; + return; + } + + this._subscriptionData.inProgress = true; + this._subscriptionData.errorMessage = ''; + let invoice = this._subscriptionData.invoice; + $.ajax({ + url: INVOICE_CHECKOUT_URL, type: 'POST', data: { + captcha: this._subscriptionData.invoiceCaptcha, hub_id: this._subscriptionData.hubId, + product_id: this.invoiceProductId(), + quantity: this._subscriptionData.quantity, session: this._subscriptionData.session, - subscription_id: subscriptionId + 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.details = data.subscription; + onCheckoutSucceeded() { + this._subscriptionData.state = 'CHECKOUT_SUCCESS'; + this._subscriptionData.invoiceCheckoutModal.open = false; this._subscriptionData.errorMessage = ''; this._subscriptionData.inProgress = false; - this._subscriptionData.shouldTransferToHub = true; - this._subscriptionData.needsTokenRefresh = true; + this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl; + this.refreshToken(); } onPostFailed(error) { @@ -426,20 +594,17 @@ class HubSubscription { updatePaymentMethod(locale) { this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; + this._subscriptionData.shouldTransferToHub = false; $.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; } @@ -453,98 +618,72 @@ 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_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.invoicePreview = null; this._subscriptionData.changeSeatsModal.confirmation = false; this._subscriptionData.changeSeatsModal.open = true; } askForChangeSeatsConfirmation() { if (!$(this._form)[0].checkValidity()) { - $(this._form).find(':input').addClass('show-invalid'); - this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + this.showInvalidFields(); return; } this._subscriptionData.changeSeatsModal.confirmation = true; - this.previewChangeQuantity(); + if (this._subscriptionData.details.processor == 'PADDLE_CLASSIC' || this._subscriptionData.details.processor == 'ESPOCRM') { + 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.changeSeatsModal.invoicePreview = data.prorated_amount != null ? data : null; this._subscriptionData.errorMessage = ''; this._subscriptionData.inProgress = false; }).fail(xhr => { @@ -556,31 +695,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.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'; @@ -590,6 +719,7 @@ class HubSubscription { } refreshToken() { + this._subscriptionData.needsTokenRefresh = true; this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; $.ajax({ @@ -608,6 +738,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; @@ -616,7 +748,6 @@ class HubSubscription { transferTokenToHub() { if (this._subscriptionData.tokenTransfer === 'queryParam') { - // Deliver the refreshed license to the Hub directly as a query parameter. 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. diff --git a/config/_default/hugo.yaml b/config/_default/hugo.yaml index b26682692..ab8cd8c19 100644 --- a/config/_default/hugo.yaml +++ b/config/_default/hugo.yaml @@ -44,6 +44,8 @@ module: target: assets/js/jquery - source: node_modules/alpinejs/dist target: assets/js/alpinejs + - source: node_modules/@alpinejs/focus/dist + target: assets/js/alpinejs-focus - source: node_modules/lazysizes target: assets/js/lazysizes - source: node_modules/js-yaml/dist diff --git a/config/development/params.yaml b/config/development/params.yaml index 21079dd44..c6e0545d3 100644 --- a/config/development/params.yaml +++ b/config/development/params.yaml @@ -21,5 +21,9 @@ paddleHubManagedYearlyPlanId: 42235 paddleHubManagedMonthlyPlanId: 82379 paddlePricesUrl: https://sandbox-checkout.paddle.com/api/2.0/prices +# ESPOCRM +espocrmHubSelfHostedProductId: 69bd302d5c65eabaa +espocrmHubManagedProductId: 69bd302d521a70103 + # STRIPE stripePk: pk_test_51RCM24IBZmkR4F9UiLBiSmsAnJvWqmHcDLxXR8ABKK1MNsZk3zCk2VJW7ZfaBlD81zpQxCX243sS3LEp9dABwiG800kJnGykDF diff --git a/config/production/params.yaml b/config/production/params.yaml index cf5881f33..1717ec2a8 100644 --- a/config/production/params.yaml +++ b/config/production/params.yaml @@ -21,5 +21,9 @@ paddleHubManagedYearlyPlanId: 807339 paddleHubManagedMonthlyPlanId: 914173 paddlePricesUrl: https://checkout.paddle.com/api/2.0/prices +# 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 e744c8e44..5eac22a60 100644 --- a/config/staging/params.yaml +++ b/config/staging/params.yaml @@ -21,5 +21,9 @@ paddleHubManagedYearlyPlanId: 42235 paddleHubManagedMonthlyPlanId: 82379 paddlePricesUrl: https://sandbox-checkout.paddle.com/api/2.0/prices +# ESPOCRM +espocrmHubSelfHostedProductId: 69bd302d5c65eabaa +espocrmHubManagedProductId: 69bd302d521a70103 + # STRIPE stripePk: pk_test_51RCM24IBZmkR4F9UiLBiSmsAnJvWqmHcDLxXR8ABKK1MNsZk3zCk2VJW7ZfaBlD81zpQxCX243sS3LEp9dABwiG800kJnGykDF 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 f4658eaf1..b6aff74cc 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -522,8 +522,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 @@ -546,12 +544,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" @@ -564,8 +560,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 @@ -588,8 +582,18 @@ 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_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." @@ -628,6 +632,71 @@ - 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_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_unit_price + translation: "Preis pro Seat" +- id: hub_billing_checkout_invoice_quantity_decrease + translation: "Anzahl der Seats verringern" +- id: hub_billing_checkout_invoice_quantity_increase + translation: "Anzahl der Seats erhöhen" +- id: hub_billing_checkout_invoice_price_loading + translation: "Preis wird geladen …" +- id: hub_billing_checkout_invoice_price_error + translation: "Der Preis konnte nicht geladen werden." +- id: hub_billing_checkout_invoice_price_retry + translation: "Erneut versuchen" + +- 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 26b5fd25b..4713bb3f1 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -522,8 +522,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 @@ -546,12 +544,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" @@ -564,8 +560,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 @@ -588,8 +582,18 @@ 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_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." @@ -628,6 +632,71 @@ - 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_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_unit_price + translation: "Price per Seat" +- id: hub_billing_checkout_invoice_quantity_decrease + translation: "Decrease number of seats" +- id: hub_billing_checkout_invoice_quantity_increase + translation: "Increase number of seats" +- id: hub_billing_checkout_invoice_price_loading + translation: "Loading price…" +- id: hub_billing_checkout_invoice_price_error + translation: "Loading the price failed." +- id: hub_billing_checkout_invoice_price_retry + translation: "Try again" + +- 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/_default/baseof.html b/layouts/_default/baseof.html index f5c56c90d..6dcef6b0c 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -144,6 +144,9 @@ {{ end }} {{ $jquery := resources.Get "js/jquery/jquery.min.js" | fingerprint }} + {{/* plugins must load before the Alpine core so they register on alpine:init */}} + {{ $alpineFocus := resources.Get "js/alpinejs-focus/cdn.min.js" | fingerprint }} + {{ $alpine := resources.Get "js/alpinejs/cdn.min.js" | fingerprint }} {{ $lazysizes := resources.Get "js/lazysizes/lazysizes.min.js" | fingerprint }} diff --git a/layouts/hub-billing/single.html b/layouts/hub-billing/single.html index 2b9942b0d..21a5d72f4 100644 --- a/layouts/hub-billing/single.html +++ b/layouts/hub-billing/single.html @@ -3,7 +3,7 @@ {{ end }} {{ define "main" }}
-
+