fix: gate PayKilla by minimum payment amount

This commit is contained in:
3252a8
2026-06-04 16:21:10 +03:00
parent bdf0622be5
commit ae7bfb9621
15 changed files with 567 additions and 25 deletions
@@ -14,6 +14,11 @@
const iconName = String(method?.icon || "").trim();
return iconName ? Icons[iconName] || null : null;
}
function disabledTitle(method) {
if (!method?.disabled || !method?.min_amount || !method?.min_currency) return "";
return `Minimum ${method.min_amount} ${method.min_currency}`;
}
</script>
<div
@@ -25,9 +30,12 @@
{@const icon = methodIcon(method)}
<button
class:active={selectedMethod === method.id}
class:disabled={method.disabled}
class="method-card"
disabled={method.disabled}
title={disabledTitle(method)}
type="button"
onclick={() => onSelect(method.id)}
onclick={() => !method.disabled && onSelect(method.id)}
>
<span class="method-card-main">
{#if icon}
@@ -4271,6 +4271,57 @@
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYKILLA_MIN_PAYMENT_AMOUNT",
"type": "float",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Minimum payment amount",
"description": "Minimum payment amount accepted through PayKilla. The value is interpreted in PAYKILLA_MIN_PAYMENT_CURRENCY and converted for tariff currencies.",
"i18n_label_key": "admin_settings_field_paykilla_min_payment_amount_label",
"i18n_description_key": "admin_settings_field_paykilla_min_payment_amount_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_min_payment_amount_placeholder",
"placeholder": "10.0",
"optional": true,
"secret": false,
"min": 0,
"provider_id": "paykilla",
"provider_label": "PayKilla",
"webhook_provider_id": "paykilla",
"webhook_path": "/webhook/paykilla",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYKILLA_MIN_PAYMENT_CURRENCY",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Minimum payment currency",
"description": "Currency for PAYKILLA_MIN_PAYMENT_AMOUNT. Default: USD.",
"i18n_label_key": "admin_settings_field_paykilla_min_payment_currency_label",
"i18n_description_key": "admin_settings_field_paykilla_min_payment_currency_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_min_payment_currency_placeholder",
"placeholder": "USD",
"optional": true,
"secret": false,
"provider_id": "paykilla",
"provider_label": "PayKilla",
"webhook_provider_id": "paykilla",
"webhook_path": "/webhook/paykilla",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"type": "bool",
+37
View File
@@ -59,6 +59,43 @@ export function priceLabel(plan, methodId = "") {
return formatMoney(plan?.price || 0, plan?.currency);
}
export function methodAmountForPlan(method, plan) {
if (!method || !plan) return 0;
if (
String(method?.id || "")
.toLowerCase()
.includes("stars") &&
Number(plan?.stars_price || 0) > 0
) {
return Number(plan.stars_price || 0);
}
return Number(plan?.price || 0);
}
export function methodAvailableForPlan(method, plan) {
if (!method || !plan) return true;
const minimum = Number(method?.min_amount || 0);
const minimumCurrency = String(method?.min_currency || "").toUpperCase();
const planCurrency = String(plan?.currency || "").toUpperCase();
if (!minimum || !minimumCurrency || minimumCurrency !== planCurrency) return true;
return methodAmountForPlan(method, plan) >= minimum;
}
export function methodsForPlan(methods, plan) {
return (methods || []).map((method) => ({
...method,
disabled: !methodAvailableForPlan(method, plan),
}));
}
export function firstAvailableMethod(methods) {
return (methods || []).find((method) => !method?.disabled)?.id || "";
}
export function methodSelectable(methods, methodId) {
return Boolean((methods || []).find((method) => method?.id === methodId && !method?.disabled));
}
export function tariffLimitLabel(tariff, { t }) {
if (!tariff) return "";
if (String(tariff.billing_model || "") === "traffic") {
+13
View File
@@ -1037,6 +1037,19 @@ a {
inset 0 1px 0 var(--inset-highlight);
}
.method-card:disabled,
.method-card.disabled {
cursor: not-allowed;
opacity: 0.46;
}
.method-card:disabled.active,
.method-card.disabled.active {
border-color: var(--border);
background: var(--surface-muted);
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.total-card {
padding: 12px 14px;
}
+16 -4
View File
@@ -26,6 +26,9 @@
planUnitHint as planUnitHintFn,
tariffLimitLabel as tariffLimitLabelFn,
priceLabel as priceLabelFn,
firstAvailableMethod,
methodSelectable,
methodsForPlan,
} from "../lib/webapp/tariffs.js";
export let createPayment = () => {};
@@ -107,6 +110,15 @@
function paymentPriceLabel(plan) {
return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod);
}
$: selectedPlanForPayment = planWithSelectedHwidRenewal(selectedPlan);
$: paymentMethods = methodsForPlan(methods, selectedPlanForPayment);
$: paymentMethodSelected = methodSelectable(paymentMethods, selectedMethod);
$: if (paymentModalOpen && paymentStep === "checkout" && selectedPlan) {
const firstMethod = firstAvailableMethod(paymentMethods);
if (firstMethod && !methodSelectable(paymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
function hwidRenewalPriceLabel(plan = selectedPlan) {
const renewal = hwidRenewalFor(plan);
if (!renewal) return "";
@@ -330,7 +342,7 @@
<div class="payment-divider" aria-hidden="true"></div>
{#if methods.length}
<PaymentMethodGrid
{methods}
methods={paymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
@@ -341,7 +353,7 @@
<Button
class="wide bottom-action payment-submit-button"
onclick={createPayment}
disabled={!selectedPlan || !methods.length || payBusy}
disabled={!selectedPlan || !paymentMethodSelected || payBusy}
>
{t("wa_pay")}
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
@@ -421,7 +433,7 @@
<div class="payment-divider" aria-hidden="true"></div>
{#if methods.length}
<PaymentMethodGrid
{methods}
methods={paymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
@@ -432,7 +444,7 @@
<Button
class="wide bottom-action payment-submit-button"
onclick={createPayment}
disabled={!selectedPlan || !methods.length || payBusy}
disabled={!selectedPlan || !paymentMethodSelected || payBusy}
>
{t("wa_pay")}
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
+49 -7
View File
@@ -7,6 +7,9 @@
planUnitHint as planUnitHintFn,
priceLabel as priceLabelFn,
actionKey as actionKeyFn,
firstAvailableMethod,
methodSelectable,
methodsForPlan,
} from "../lib/webapp/tariffs.js";
import { premiumTitle as premiumTitleFn } from "../lib/webapp/traffic.js";
import { formatCompactNumber } from "../lib/webapp/formatters.js";
@@ -60,6 +63,31 @@
return actionKeyFn(action);
}
$: changePaymentMethods = methodsForPlan(methods, selectedChangeAction);
$: topupPaymentMethods = methodsForPlan(methods, selectedTopupPlan);
$: devicePaymentMethods = methodsForPlan(methods, selectedDeviceTopupPlan);
$: changePaymentMethodSelected = methodSelectable(changePaymentMethods, selectedMethod);
$: topupPaymentMethodSelected = methodSelectable(topupPaymentMethods, selectedMethod);
$: devicePaymentMethodSelected = methodSelectable(devicePaymentMethods, selectedMethod);
$: if (changeModalOpen && selectedChangeAction?.kind === "payment") {
const firstMethod = firstAvailableMethod(changePaymentMethods);
if (firstMethod && !methodSelectable(changePaymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
$: if (topupModalOpen && selectedTopupPlan) {
const firstMethod = firstAvailableMethod(topupPaymentMethods);
if (firstMethod && !methodSelectable(topupPaymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
$: if (deviceTopupModalOpen && selectedDeviceTopupPlan) {
const firstMethod = firstAvailableMethod(devicePaymentMethods);
if (firstMethod && !methodSelectable(devicePaymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
function changeActionTitle(action) {
const mode = String(action?.mode || "");
if (mode === "recalc_days") {
@@ -253,7 +281,7 @@
</div>
{#if selectedChangeAction?.kind === "payment"}
<PaymentMethodGrid
{methods}
methods={changePaymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
@@ -262,7 +290,9 @@
<Button
class="wide bottom-action payment-submit-button"
onclick={openTariffChangeConfirm}
disabled={tariffActionBusy || payBusy}
disabled={tariffActionBusy ||
payBusy ||
(selectedChangeAction?.kind === "payment" && !changePaymentMethodSelected)}
>
{selectedChangeAction?.kind === "payment" ? t("wa_pay") : t("wa_apply")}
<ArrowRight size={17} />
@@ -293,7 +323,9 @@
<Button
class="wide bottom-action payment-submit-button"
onclick={applyTariffChange}
disabled={tariffActionBusy || payBusy}
disabled={tariffActionBusy ||
payBusy ||
(selectedChangeAction?.kind === "payment" && !changePaymentMethodSelected)}
>
{selectedChangeAction?.kind === "payment"
? t("wa_confirm_and_pay")
@@ -354,11 +386,16 @@
{/each}
</div>
{/if}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<PaymentMethodGrid
methods={topupPaymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
<Button
class="wide bottom-action payment-submit-button"
onclick={createTopupPayment}
disabled={!selectedTopupPlan || !methods.length || payBusy}
disabled={!selectedTopupPlan || !topupPaymentMethodSelected || payBusy}
>
{t("wa_buy_traffic")}
{selectedTopupPlan ? priceLabel(selectedTopupPlan) : ""}
@@ -413,11 +450,16 @@
</button>
{/each}
</div>
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<PaymentMethodGrid
methods={devicePaymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
<Button
class="wide bottom-action payment-submit-button"
onclick={createDeviceTopupPayment}
disabled={!selectedDeviceTopupPlan || !methods.length || payBusy}
disabled={!selectedDeviceTopupPlan || !devicePaymentMethodSelected || payBusy}
>
{t("wa_pay")}
{selectedDeviceTopupPlan ? priceLabel(selectedDeviceTopupPlan) : ""}