diff --git a/js/payments/platformFee.js b/js/payments/platformFee.js new file mode 100644 index 00000000..c448b15a --- /dev/null +++ b/js/payments/platformFee.js @@ -0,0 +1,74 @@ +// js/payments/platformFee.js + +import { PLATFORM_FEE_PERCENT } from "../config.js"; + +export function parsePercentValue(value) { + if (typeof value === "number") { + return Number.isFinite(value) ? value : NaN; + } + + if (typeof value === "bigint") { + return Number(value); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return NaN; + } + + if (trimmed.includes("/")) { + const [firstRaw, secondRaw] = trimmed.split("/").map((part) => part.trim()); + if (firstRaw && secondRaw) { + const first = Number(firstRaw); + const second = Number(secondRaw); + if (Number.isFinite(first) && Number.isFinite(second) && first >= 0 && second >= 0) { + const total = first + second; + if (total > 0) { + return (second / total) * 100; + } + } + } + } + + const percentStripped = trimmed.endsWith("%") ? trimmed.slice(0, -1).trim() : trimmed; + const directNumeric = Number(percentStripped); + if (Number.isFinite(directNumeric)) { + return directNumeric; + } + + const match = percentStripped.match(/-?\d+(?:\.\d+)?/); + if (match) { + const fallback = Number(match[0]); + return Number.isFinite(fallback) ? fallback : NaN; + } + + return NaN; + } + + return Number.isFinite(value) ? Number(value) : NaN; +} + +export function clampPercent(value) { + const numeric = parsePercentValue(value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.min(100, Math.max(0, numeric)); +} + +export function getDefaultPlatformFeePercent() { + const parsed = parsePercentValue(PLATFORM_FEE_PERCENT); + if (!Number.isFinite(parsed)) { + return 0; + } + return clampPercent(parsed); +} + +export function resolvePlatformFeePercent(overrideValue) { + const parsedOverride = parsePercentValue(overrideValue); + if (Number.isFinite(parsedOverride)) { + return clampPercent(parsedOverride); + } + return getDefaultPlatformFeePercent(); +} diff --git a/js/payments/zapSharedState.js b/js/payments/zapSharedState.js index 5aaccc04..21cc7746 100644 --- a/js/payments/zapSharedState.js +++ b/js/payments/zapSharedState.js @@ -1,4 +1,4 @@ -import { PLATFORM_FEE_PERCENT } from "../config.js"; +import { resolvePlatformFeePercent } from "./platformFee.js"; import { resolveLightningAddress as defaultResolveLightningAddress, fetchPayServiceData as defaultFetchPayServiceData, @@ -112,11 +112,9 @@ export async function fetchLightningMetadata( export function calculateZapShares(amount, overrideFee = null) { const numericAmount = Math.max(0, Math.round(Number(amount) || 0)); - const percentSource = - typeof overrideFee === "number" && Number.isFinite(overrideFee) - ? overrideFee - : PLATFORM_FEE_PERCENT; - const feePercent = Math.min(100, Math.max(0, Math.round(percentSource))); + const feePercent = resolvePlatformFeePercent( + typeof overrideFee === "undefined" ? null : overrideFee + ); const platformShare = Math.floor((numericAmount * feePercent) / 100); const creatorShare = numericAmount - platformShare; return { diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index a42ba18d..06bb25ff 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -1,6 +1,6 @@ // js/payments/zapSplit.js -import { PLATFORM_FEE_PERCENT, ADMIN_SUPER_NPUB } from "../config.js"; +import { ADMIN_SUPER_NPUB } from "../config.js"; import { resolveLightningAddress, fetchPayServiceData, @@ -9,6 +9,11 @@ import { } from "./lnurl.js"; import { ensureWallet, sendPayment } from "./nwcClient.js"; import { getPlatformLightningAddress } from "./platformAddress.js"; +import { + clampPercent, + parsePercentValue, + resolvePlatformFeePercent, +} from "./platformFee.js"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; const ZAP_KIND = 9734; @@ -24,19 +29,7 @@ const DEFAULT_DEPS = Object.freeze({ }); function getOverridePlatformFee() { - const override = globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__; - if (Number.isFinite(override)) { - return override; - } - return PLATFORM_FEE_PERCENT; -} - -function clampPercent(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return 0; - } - return Math.min(100, Math.max(0, numeric)); + return resolvePlatformFeePercent(globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__); } function sanitizeAmount(amount) { @@ -341,4 +334,6 @@ export const __TESTING__ = Object.freeze({ determineRecipientPubkey, derivePointerTag, mergeDependencies, + parsePercentValue, + clampPercent, }); diff --git a/tests/zap-shared-state.test.mjs b/tests/zap-shared-state.test.mjs new file mode 100644 index 00000000..f3f11e98 --- /dev/null +++ b/tests/zap-shared-state.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; + +const sharedStateModule = await import("../js/payments/zapSharedState.js"); +const feeModule = await import("../js/payments/platformFee.js"); + +const { calculateZapShares } = sharedStateModule; +const { resolvePlatformFeePercent } = feeModule; + +(function testCalculateZapSharesSupportsRatioOverride() { + const result = calculateZapShares(35, "70/30"); + assert.equal(result.total, 35); + assert.equal(result.platformShare, 10); + assert.equal(result.creatorShare, 25); + assert.equal(result.feePercent, 30); +})(); + +(function testCalculateZapSharesHandlesPercentStrings() { + const result = calculateZapShares(200, "30% "); + assert.equal(result.platformShare, 60); + assert.equal(result.creatorShare, 140); + assert.equal(result.feePercent, 30); +})(); + +(function testResolvePlatformFeePercentFallsBackToDefault() { + const percent = resolvePlatformFeePercent("not-a-number"); + assert.equal(percent, 0); +})(); + +console.log("zap-shared-state tests passed"); diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 22962299..b683ce2c 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -180,6 +180,42 @@ async function testSplitMath() { delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; } +async function testStringFeeOverride() { + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + tags: [["d", "pointer"]], + kind: 30078, + }; + + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = "70/30"; + const seventyThirty = createDeps(); + const ratioResult = await splitAndZap( + { videoEvent, amountSats: 1000, comment: "Great video!" }, + seventyThirty.deps + ); + + assert.equal(ratioResult.creatorShare, 700); + assert.equal(ratioResult.platformShare, 300); + const ratioSendCalls = seventyThirty.getSendCalls(); + assert.equal(ratioSendCalls.length, 2, "should send two payments for ratio"); + assert.equal(ratioSendCalls[0].amountSats, 700); + assert.equal(ratioSendCalls[1].amountSats, 300); + + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = "30%"; + const percentOverride = createDeps(); + const percentResult = await splitAndZap( + { videoEvent, amountSats: 200, comment: "Another zap" }, + percentOverride.deps + ); + + assert.equal(percentResult.creatorShare, 140); + assert.equal(percentResult.platformShare, 60); + + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; +} + async function testLnurlBounds() { globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; const depsWrapper = createDeps({ minSendable: 5_000 }); @@ -265,5 +301,6 @@ await testSplitMath(); await testLnurlBounds(); await testMissingAddress(); await testWalletFailure(); +await testStringFeeOverride(); console.log("zap-split tests passed");