diff --git a/CHANGELOG.md b/CHANGELOG.md index 79abd1d..d238804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Freemius for WordPress are documented in this file. ## [Unreleased] +- added: "Discounted price" mapping field that applies the scope coupon discount to the displayed plan price + ## [0.4.2] - fixed: missing import for MappingSettings diff --git a/docs/README.md b/docs/README.md index 034e1c7..32f3572 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,11 +64,12 @@ The following blocks can "receive" data from the scope: - HeadingBlock - Button Block -Currently, 5 fields are supported: +Currently, 6 fields are supported: - Title - Description - Price +- Discounted price (requires a coupon code in scope settings) - Licenses (1, 2, 3, Unlimited) - Billing Cycle (Monthly, Yearly, Lifetime) diff --git a/includes/class-freemius-api.php b/includes/class-freemius-api.php index 94dda28..0804df9 100644 --- a/includes/class-freemius-api.php +++ b/includes/class-freemius-api.php @@ -479,6 +479,10 @@ private function get_dummy_response( string $endpoint ) { $body = $dummy['pricing']; } + if ( substr( $endpoint, -strlen( '/coupons.json' ) ) === '/coupons.json' ) { + $body = $dummy['coupons']; + } + if ( substr( $endpoint, -strlen( '/products/19794.json' ) ) === '/products/19794.json' ) { $body = $dummy['product']; } @@ -515,6 +519,7 @@ private function load_dummy_response_data(): array { 'pricing' => $pricing, 'currencies' => $currencies, 'product' => $product, + 'coupons' => $coupons, ); return $data; diff --git a/includes/class-freemius-coupon.php b/includes/class-freemius-coupon.php new file mode 100644 index 0000000..3abb742 --- /dev/null +++ b/includes/class-freemius-coupon.php @@ -0,0 +1,144 @@ + + * @license MIT + * @link https://freemius.com/ + */ + +namespace Freemius; + +/** + * Class Coupon + * + * @package Freemius + * @since 0.5.0 + */ +class Coupon { + /** + * Fetch coupon metadata by code. + * + * @since 0.5.0 + * + * @param int $product_id Product ID. + * @param string $code Coupon code. + * @return array|null Coupon data or null when not found. + */ + public static function get_by_code( int $product_id, string $code ): ?array { + if ( '' === $code ) { + return null; + } + + $api = Api::get_instance(); + $result = $api->get_request( + 'products/' . $product_id . '/coupons.json', + array( 'code' => $code ) + ); + + if ( \is_wp_error( $result ) ) { + return null; + } + + $data = $result->get_data(); + + if ( empty( $data['coupons'][0] ) || ! \is_array( $data['coupons'][0] ) ) { + return null; + } + + return $data['coupons'][0]; + } + + /** + * Whether a coupon applies to the given plan. + * + * @since 0.5.0 + * + * @param array $coupon Coupon metadata. + * @param int|string|null $plan_id Plan ID. + * @return bool + */ + public static function applies_to_plan( array $coupon, $plan_id ): bool { + $plan_ids = self::normalize_plan_ids( $coupon['plans'] ?? null ); + + if ( empty( $plan_ids ) ) { + return true; + } + + return \in_array( (string) $plan_id, $plan_ids, true ); + } + + /** + * Normalize coupon plan restrictions from API payloads. + * + * @since 0.5.0 + * + * @param mixed $plans Plan restriction from API. + * @return array + */ + private static function normalize_plan_ids( $plans ): array { + if ( null === $plans || '' === $plans ) { + return array(); + } + + if ( \is_array( $plans ) ) { + return \array_values( + \array_filter( + \array_map( + static function ( $plan_id ) { + return \trim( (string) $plan_id ); + }, + $plans + ) + ) + ); + } + + return \array_values( + \array_filter( \array_map( 'trim', \explode( ',', (string) $plans ) ) ) + ); + } + + /** + * Apply coupon discount to a base price. + * + * @since 0.5.0 + * + * @param float $price List price. + * @param array $coupon Coupon metadata. + * @param string $currency Currency code. + * @return float Discounted price, floored at zero. + */ + public static function apply_discount( float $price, array $coupon, string $currency ): float { + $discounted = $price; + + if ( isset( $coupon['discount_type'] ) && 'percentage' === $coupon['discount_type'] ) { + $discounted = $price * ( 1 - ( (float) $coupon['discount'] / 100 ) ); + } else { + $currency_key = strtolower( $currency ); + $amount = $coupon['discounts'][ $currency_key ] ?? (float) $coupon['discount']; + $discounted = $price - (float) $amount; + } + + return max( 0.0, $discounted ); + } + + /** + * Coupon fields to embed on the frontend. + * + * @since 0.5.0 + * + * @param array $coupon Full coupon payload. + * @return array + */ + public static function to_embed_data( array $coupon ): array { + return array( + 'discount' => $coupon['discount'] ?? 0, + 'discount_type' => $coupon['discount_type'] ?? 'percentage', + 'plans' => $coupon['plans'] ?? null, + 'discounts' => $coupon['discounts'] ?? array(), + ); + } +} diff --git a/includes/class-freemius-scope.php b/includes/class-freemius-scope.php index 3f46cfd..bbf83f0 100644 --- a/includes/class-freemius-scope.php +++ b/includes/class-freemius-scope.php @@ -29,10 +29,19 @@ class Scope { /** * Whether the matrix has been added to the content * - * @var boolean + * @var array */ private $matrix_added = array(); + /** + * Coupon scripts already added to the page (product_id:coupon_code). + * + * @since 0.5.0 + * + * @var array + */ + private $coupon_added = array(); + /** * Constructor @@ -145,6 +154,26 @@ public function render_scope( $block_content, $block, $instance ) { // phpcs:ign $this->matrix_added[] = $product_id; } + $coupon_code = $args['coupon'] ?? ''; + if ( $product_id && ! empty( $coupon_code ) ) { + $coupon_key = $product_id . ':' . $coupon_code; + + if ( ! in_array( $coupon_key, $this->coupon_added, true ) ) { + $coupon = Coupon::get_by_code( (int) $product_id, (string) $coupon_code ); + + if ( null !== $coupon ) { + $extra .= sprintf( + '', + esc_attr( (string) $product_id ), + esc_attr( (string) $coupon_code ), + \wp_json_encode( Coupon::to_embed_data( $coupon ) ) + ); + + $this->coupon_added[] = $coupon_key; + } + } + } + $block_content = $extra . $block_content; return $block_content; diff --git a/includes/dummy-response.php b/includes/dummy-response.php index e6f35ff..020085a 100644 --- a/includes/dummy-response.php +++ b/includes/dummy-response.php @@ -18,3 +18,5 @@ $currencies = '{"currencies":["usd","gbp","eur"]}'; $product = '{"parent_plugin_id":null,"developer_id":"15334","store_id":"9196","install_id":"18226462","slug":"test-plugin","title":"Test Plugin","environment":0,"icon":"https:\/\/s3-us-west-2.amazonaws.com\/freemius\/plugins\/19794\/icons\/54c7fc13fab5c4b5acd1eebbc3aacce6.jpg","default_plan_id":"32841","plans":"32841,32842,32843","features":null,"money_back_period":null,"refund_policy":null,"annual_renewals_discount":null,"renewals_discount_type":"percentage","is_released":true,"is_sdk_required":true,"is_pricing_visible":true,"is_wp_org_compliant":true,"is_off":false,"is_only_for_new_installs":false,"installs_limit":null,"installs_count":0,"active_installs_count":0,"free_releases_count":0,"premium_releases_count":0,"total_purchases":0,"total_subscriptions":0,"total_renewals":0,"earnings":0,"commission":"","accepted_payments":0,"plan_id":"0","type":"plugin","is_static":false,"secret_key":"sk__","public_key":"pk_07813324de199d9ccfe7a4ff4f013","id":"19794","created":"2025-07-11 11:33:09","updated":"2025-08-25 10:54:52","helpscout_secret_key":"xxx"}'; + +$coupons = '{"coupons":[{"code":"SAVE20","discount":10,"discount_type":"percentage","plans":null,"discounts":{}}]}'; diff --git a/src/blocks/modifier/view.js b/src/blocks/modifier/view.js index 40219d9..c2445a2 100644 --- a/src/blocks/modifier/view.js +++ b/src/blocks/modifier/view.js @@ -10,6 +10,12 @@ import { store, getContext, getElement } from '@wordpress/interactivity'; /** * Internal dependencies */ +import { + applyCouponDiscount, + couponAppliesToPlan, + formatMappingPrice, + getCouponData, +} from '../../util/discountedPrice'; /** * Get the mapping content based on the mapping type and data @@ -52,6 +58,13 @@ function getMappingContent( scopeData, mappingData ) { let content = ''; + const licensesKey = sd.licenses || 'unlimited'; + + const getBasePrice = () => + matrix[ sd.plan_id ]?.pricing?.[ sd.currency ]?.[ sd.billing_cycle ]?.[ + licensesKey + ] ?? 0; + switch ( md.field ) { case 'billing_cycle': content = md.labels?.[ sd.billing_cycle ] ?? sd.billing_cycle; @@ -60,30 +73,42 @@ function getMappingContent( scopeData, mappingData ) { content = md.labels?.[ sd.licenses ] ?? sd.licenses; break; case 'price': - content = - matrix[ sd.plan_id ]?.pricing?.[ sd.currency ]?.[ - sd.billing_cycle - ]?.[ sd.licenses || 'unlimited' ] ?? 0; + case 'discounted_price': { + content = getBasePrice(); + + if ( md.field === 'discounted_price' ) { + if ( ! sd.coupon ) { + content = ''; + break; + } + + const coupon = getCouponData( sd.product_id, sd.coupon ); + + if ( ! coupon || ! couponAppliesToPlan( coupon, sd.plan_id ) ) { + content = ''; + break; + } + + content = applyCouponDiscount( content, coupon, { + currency: sd.currency, + } ); + } // it's an invalid plan if ( ! content && matrix[ sd.plan_id ]?.pricing !== null ) { - content = '-'; + content = md.field === 'discounted_price' ? '' : '-'; break; } - const symbol = md.currency_symbol; - - content = new Intl.NumberFormat( 'en-US', { - style: symbol !== 'hide' ? 'currency' : 'decimal', - currency: symbol !== 'hide' ? sd.currency : undefined, - minimumFractionDigits: 0, - } ).format( content ); + if ( content === '' ) break; - // extract the currency symbol - if ( symbol === 'symbol' ) - content = content.replace( /[\d\s.,]/g, '' ).trim(); + content = formatMappingPrice( content, { + currency: sd.currency, + currency_symbol: md.currency_symbol, + } ); break; + } default: content = matrix[ sd.plan_id ]?.[ md.field ] ?? ''; break; diff --git a/src/hooks/index.js b/src/hooks/index.js index 5473951..7ed919b 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -15,6 +15,7 @@ import useModifiers from './useModifiers'; import useLicenses from './useLicenses'; import usePlans from './usePlans'; import useProducts from './useProducts'; +import useCoupon from './useCoupon'; export { useSettings, @@ -26,6 +27,7 @@ export { useLicenses, usePlans, useProducts, + useCoupon, }; export { diff --git a/src/hooks/useCoupon.js b/src/hooks/useCoupon.js new file mode 100644 index 0000000..4b826ef --- /dev/null +++ b/src/hooks/useCoupon.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useApiGet } from './useApi'; + +/** + * Fetch coupon metadata for a product by code. + * + * @param {number|string|null} productId Freemius product ID. + * @param {string|null} couponCode Coupon code from scope settings. + * @return {Object} Coupon data and request state. + */ +const useCoupon = ( productId, couponCode ) => { + const enabled = Boolean( productId && couponCode ); + + const { data, isLoading, error, isApiAvailable } = useApiGet( + enabled ? `products/${ productId }/coupons.json` : null, + enabled ? { code: couponCode } : {}, + { enabled } + ); + + const coupon = useMemo( () => { + if ( ! enabled || ! data?.coupons?.length ) return null; + + return data.coupons[ 0 ]; + }, [ data, enabled ] ); + + const notFound = enabled && ! isLoading && ! error && ! coupon; + + return { + coupon, + isLoading: enabled && isApiAvailable && isLoading, + isError: Boolean( error ), + notFound, + }; +}; + +export default useCoupon; diff --git a/src/hooks/useMapping.js b/src/hooks/useMapping.js index 525c536..192aaca 100644 --- a/src/hooks/useMapping.js +++ b/src/hooks/useMapping.js @@ -11,7 +11,12 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useData, usePlans, useLicenses } from './'; +import { useData, usePlans, useLicenses, useCoupon } from './'; +import { + applyCouponDiscount, + couponAppliesToPlan, + formatMappingPrice, +} from '../util/discountedPrice'; const useMapping = ( props ) => { const { attributes, setAttributes } = props; @@ -24,6 +29,18 @@ const useMapping = ( props ) => { data?.product_id ); + const needsCoupon = freemius_mapping?.field === 'discounted_price'; + + const { + coupon, + isLoading: isCouponLoading, + isError: isCouponError, + notFound: isCouponNotFound, + } = useCoupon( + needsCoupon ? data?.product_id : null, + needsCoupon ? data?.coupon : null + ); + const defaultLabels = useMemo( () => { return { licenses: licenses.reduce( ( acc, license ) => { @@ -68,11 +85,33 @@ const useMapping = ( props ) => { setAttributes( newMapping ); }; - const value = getMappingValue( options ); + const couponContext = useMemo( + () => ( { + coupon, + isCouponLoading, + isCouponError, + isCouponNotFound, + } ), + [ coupon, isCouponLoading, isCouponError, isCouponNotFound ] + ); + + const value = getMappingValue( options, couponContext ); const errorMessage = []; - if ( value === undefined ) + if ( options.field === 'discounted_price' ) + if ( ! data?.coupon ) + errorMessage.push( + __( 'Coupon is required in scope settings', 'freemius' ) + ); + else if ( isCouponError || isCouponNotFound ) + errorMessage.push( __( 'Coupon not found', 'freemius' ) ); + else if ( coupon && ! couponAppliesToPlan( coupon, data?.plan_id ) ) + errorMessage.push( + __( 'Coupon does not apply to this plan', 'freemius' ) + ); + + if ( value === undefined && errorMessage.length === 0 ) errorMessage.push( sprintf( __( 'No value found for field %s', 'freemius' ), @@ -80,35 +119,28 @@ const useMapping = ( props ) => { ) ); - // if (!data.public_key) { - // errorMessage.push(__('Public key is required', 'freemius')); - // } - - // if (!data.product_id) { - // errorMessage.push(__('Product ID is required', 'freemius')); - // } - - // if (!data.plan_id) { - // errorMessage.push(__('Plan ID is required', 'freemius')); - // } - const isError = - ! isLoading && ! isLicensesLoading && errorMessage.length > 0; + ! isLoading && + ! isLicensesLoading && + ! isCouponLoading && + errorMessage.length > 0; return { value, options, setMapping, defaultLabels, - isLoading: isLicensesLoading || isLoading, + isLoading: isLicensesLoading || isLoading || isCouponLoading, isError, errorMessage: errorMessage.join( ', ' ), }; }; -const getMappingValue = ( options ) => { +const getMappingValue = ( options, couponContext = {} ) => { const { data, isLoading: isDataLoading } = useData(); const { plans, isLoading: isPlansLoading } = usePlans( data?.product_id ); + const { coupon, isCouponLoading, isCouponError, isCouponNotFound } = + couponContext; const currentPlan = useMemo( () => { return plans?.find( ( plan ) => plan.id == data?.plan_id ); @@ -123,47 +155,73 @@ const getMappingValue = ( options ) => { } ); }, [ currentPlan, data ] ); + const currency = + data?.currency && data?.currency !== 'auto' ? data?.currency : 'usd'; + const mappingData = useMemo( () => { + const basePrice = + currentPricing?.[ data?.billing_cycle + '_price' ] || undefined; + + let discountedPrice; + + if ( + options.field === 'discounted_price' && + basePrice !== undefined && + coupon && + couponAppliesToPlan( coupon, data?.plan_id ) + ) + discountedPrice = applyCouponDiscount( basePrice, coupon, { + currency, + } ); + return { - price: - currentPricing?.[ data?.billing_cycle + '_price' ] || undefined, // Free plan has no pricing - currency: - data?.currency && data?.currency !== 'auto' - ? data?.currency - : 'usd', + price: basePrice, + discounted_price: discountedPrice, + currency, title: currentPlan?.title || null, licenses: currentPricing?.licenses === null ? 0 - : currentPricing?.licenses, // handle unlimited license + : currentPricing?.licenses, billing_cycle: data?.billing_cycle, description: currentPlan?.description || null, }; - }, [ currentPricing, currentPlan, data ] ); + }, [ currentPricing, currentPlan, data, options.field, coupon, currency ] ); const newContent = useMemo( () => { + if ( options.field === 'discounted_price' ) { + if ( ! data?.coupon ) return undefined; + + if ( + isCouponLoading || + isCouponError || + isCouponNotFound || + ( coupon && ! couponAppliesToPlan( coupon, data?.plan_id ) ) + ) + return undefined; + } + let content = mappingData[ options.field ]; if ( typeof content === 'undefined' ) { // plans are loaded, but no pricing found => free plan if ( isPlansLoading ) return undefined; + if ( options.field === 'discounted_price' ) return undefined; + content = '0'; } - if ( options.field === 'price' && ! isNaN( content ) ) { - const symbol = options.currency_symbol; - - content = new Intl.NumberFormat( 'en-US', { - style: symbol !== 'hide' ? 'currency' : 'decimal', - currency: symbol !== 'hide' ? mappingData.currency : undefined, - minimumFractionDigits: 0, - } ).format( content ); - - // extract the currency symbol - if ( symbol === 'symbol' ) - content = content.replace( /[\d\s.,]/g, '' ).trim(); - } else if ( options.field === 'billing_cycle' ) + if ( + ( options.field === 'price' || + options.field === 'discounted_price' ) && + ! isNaN( content ) + ) + content = formatMappingPrice( content, { + currency: mappingData.currency, + currency_symbol: options.currency_symbol, + } ); + else if ( options.field === 'billing_cycle' ) content = options.labels[ mappingData.billing_cycle ] ?? mappingData.billing_cycle; @@ -178,9 +236,18 @@ const getMappingValue = ( options ) => { content = options.prefix + content + options.suffix; return content; - }, [ mappingData, options, isPlansLoading ] ); - - if ( isPlansLoading || isDataLoading ) return undefined; + }, [ + mappingData, + options, + isPlansLoading, + data, + coupon, + isCouponLoading, + isCouponError, + isCouponNotFound, + ] ); + + if ( isPlansLoading || isDataLoading || isCouponLoading ) return undefined; return newContent; }; diff --git a/src/scope/MappingSettings.js b/src/scope/MappingSettings.js index 4290eff..66db351 100644 --- a/src/scope/MappingSettings.js +++ b/src/scope/MappingSettings.js @@ -104,6 +104,10 @@ const MappingSettings = ( props ) => { name: __( 'Price', 'freemius' ), id: 'price', }, + { + name: __( 'Discounted price', 'freemius' ), + id: 'discounted_price', + }, { name: __( 'Title', 'freemius' ), id: 'title', @@ -125,7 +129,8 @@ const MappingSettings = ( props ) => { { options.field && ( <> - { options.field === 'price' && ( + { ( options.field === 'price' || + options.field === 'discounted_price' ) && ( |null|undefined} plans Plan restriction from API. + * @return {string[]|null} Plan IDs, or null when the coupon applies to all plans. + */ +function normalizePlanIds( plans ) { + if ( plans == null || plans === '' ) return null; + + if ( Array.isArray( plans ) ) + return plans.map( ( id ) => String( id ).trim() ).filter( Boolean ); + + if ( typeof plans === 'number' ) return [ String( plans ) ]; + + if ( typeof plans === 'string' ) + return plans + .split( ',' ) + .map( ( id ) => id.trim() ) + .filter( Boolean ); + + return null; +} + +/** + * Whether a coupon applies to the given plan. + * + * @param {Object} coupon Coupon metadata from the Freemius API. + * @param {number|null} planId Plan ID from scope data. + * @return {boolean} True when the coupon applies to the plan. + */ +export function couponAppliesToPlan( coupon, planId ) { + const planIds = normalizePlanIds( coupon?.plans ); + + if ( ! planIds?.length ) return true; + + return planIds.includes( String( planId ) ); +} + +/** + * Apply coupon discount to a base price. + * + * @param {number} basePrice List price before discount. + * @param {Object} coupon Coupon metadata from the Freemius API. + * @param {Object} options Options. + * @param {string} options.currency Currency code for fixed discounts. + * @return {number} Discounted price, floored at zero. + */ +export function applyCouponDiscount( basePrice, coupon, { currency } ) { + const price = Number( basePrice ); + + if ( ! coupon || isNaN( price ) ) return price; + + let discounted = price; + + if ( coupon.discount_type === 'percentage' ) + discounted = price * ( 1 - Number( coupon.discount ) / 100 ); + else { + const currencyKey = currency?.toLowerCase(); + const amount = + coupon.discounts?.[ currencyKey ] ?? Number( coupon.discount ); + + discounted = price - amount; + } + + return Math.max( 0, discounted ); +} + +/** + * Format a numeric price for mapping output. + * + * @param {number} value Numeric price. + * @param {Object} options Formatting options. + * @param {string} options.currency Currency code. + * @param {string} options.currency_symbol show | hide | symbol. + * @return {string} Formatted price string. + */ +export function formatMappingPrice( value, { currency, currency_symbol } ) { + const symbol = currency_symbol ?? 'show'; + const numericValue = Number( value ); + const hasFractionalCents = Math.round( numericValue * 100 ) % 100 !== 0; + const fractionDigits = hasFractionalCents ? 2 : 0; + + let content = new Intl.NumberFormat( 'en-US', { + style: symbol !== 'hide' ? 'currency' : 'decimal', + currency: symbol !== 'hide' ? currency : undefined, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + } ).format( numericValue ); + + if ( fractionDigits > 0 ) { + const decimalSeparatorIndex = content.lastIndexOf( '.' ); + + if ( decimalSeparatorIndex !== -1 ) + content = + content.slice( 0, decimalSeparatorIndex ) + + ',' + + content.slice( decimalSeparatorIndex + 1 ); + } + + if ( symbol === 'symbol' ) + content = content.replace( /[\d\s.,]/g, '' ).trim(); + + return content; +} + +/** + * Read embedded coupon metadata from the page. + * + * @param {number|string} productId Product ID. + * @param {string} couponCode Coupon code from scope. + * @return {Object|null} Coupon metadata or null when not embedded. + */ +export function getCouponData( productId, couponCode ) { + const nodes = document.querySelectorAll( '.freemius-coupon-data' ); + + for ( const node of nodes ) + if ( + node.dataset.freemiusProductId == productId && + node.dataset.freemiusCouponCode === couponCode + ) + return JSON.parse( node.textContent ); + + return null; +} diff --git a/src/util/index.js b/src/util/index.js index 8d541a5..3bf6b3d 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -28,3 +28,10 @@ const Dump = ( { props, title = '', visible = true } ) => { }; export default Dump; + +export { + applyCouponDiscount, + couponAppliesToPlan, + formatMappingPrice, + getCouponData, +} from './discountedPrice'; diff --git a/tests/php/CouponTest.php b/tests/php/CouponTest.php new file mode 100644 index 0000000..7c7ef83 --- /dev/null +++ b/tests/php/CouponTest.php @@ -0,0 +1,119 @@ + null, + ); + + $this->assertTrue( Coupon::applies_to_plan( $coupon, 32842 ) ); + } + + public function test_applies_to_plan_when_plan_is_listed(): void { + $coupon = array( + 'plans' => '32841,32842', + ); + + $this->assertTrue( Coupon::applies_to_plan( $coupon, 32842 ) ); + $this->assertFalse( Coupon::applies_to_plan( $coupon, 32843 ) ); + } + + public function test_applies_to_plan_when_plans_is_array(): void { + $coupon = array( + 'plans' => array( 32841, 32842 ), + ); + + $this->assertTrue( Coupon::applies_to_plan( $coupon, 32842 ) ); + $this->assertFalse( Coupon::applies_to_plan( $coupon, 32843 ) ); + } + + public function test_apply_discount_percentage(): void { + $coupon = array( + 'discount' => 10, + 'discount_type' => 'percentage', + ); + + $this->assertSame( 45.0, Coupon::apply_discount( 50.0, $coupon, 'usd' ) ); + } + + public function test_apply_discount_dollar(): void { + $coupon = array( + 'discount' => 5, + 'discount_type' => 'dollar', + ); + + $this->assertSame( 45.0, Coupon::apply_discount( 50.0, $coupon, 'usd' ) ); + } + + public function test_apply_discount_dollar_uses_currency_specific_amount(): void { + $coupon = array( + 'discount' => 5, + 'discount_type' => 'dollar', + 'discounts' => array( + 'eur' => 9, + ), + ); + + $this->assertSame( 41.0, Coupon::apply_discount( 50.0, $coupon, 'eur' ) ); + } + + public function test_apply_discount_floors_at_zero(): void { + $coupon = array( + 'discount' => 100, + 'discount_type' => 'dollar', + ); + + $this->assertSame( 0.0, Coupon::apply_discount( 50.0, $coupon, 'usd' ) ); + } + + public function test_to_embed_data_returns_frontend_fields_only(): void { + $embedded = Coupon::to_embed_data( + array( + 'code' => 'SAVE20', + 'discount' => 10, + 'discount_type' => 'percentage', + 'plans' => '32842', + 'discounts' => array( 'usd' => 10 ), + 'redemptions' => 5, + ) + ); + + $this->assertSame( + array( + 'discount' => 10, + 'discount_type' => 'percentage', + 'plans' => '32842', + 'discounts' => array( 'usd' => 10 ), + ), + $embedded + ); + } + + public function test_get_by_code_returns_null_for_empty_code(): void { + $this->assertNull( Coupon::get_by_code( 19794, '' ) ); + } + + public function test_get_by_code_returns_coupon_from_api(): void { + $this->options['freemius_settings'] = array( + 'token' => '1234567890', + ); + + $coupon = Coupon::get_by_code( 19794, 'SAVE20' ); + + $this->assertIsArray( $coupon ); + $this->assertSame( 'SAVE20', $coupon['code'] ); + $this->assertSame( 'percentage', $coupon['discount_type'] ); + } +} diff --git a/tests/php/Freemius_TestCase.php b/tests/php/Freemius_TestCase.php index 600eca4..bcb7135 100644 --- a/tests/php/Freemius_TestCase.php +++ b/tests/php/Freemius_TestCase.php @@ -165,6 +165,11 @@ protected function reset_singletons(): void { $matrix->setAccessible( true ); $matrix->setValue( $scope, array() ); } + if ( $ref->hasProperty( 'coupon_added' ) ) { + $coupon_added = $ref->getProperty( 'coupon_added' ); + $coupon_added->setAccessible( true ); + $coupon_added->setValue( $scope, array() ); + } $api = Api::get_instance(); $api_ref = new ReflectionClass( $api ); diff --git a/tests/php/ScopeTest.php b/tests/php/ScopeTest.php index 4829984..5824be0 100644 --- a/tests/php/ScopeTest.php +++ b/tests/php/ScopeTest.php @@ -109,4 +109,52 @@ public function test_render_scope_deduplicates_matrix_per_product(): void { $this->assertSame( 1, substr_count( $first, 'freemius-matrix-data' ) ); $this->assertSame( 0, substr_count( $second, 'freemius-matrix-data' ) ); } + + public function test_render_scope_injects_coupon_data_when_coupon_is_set(): void { + $this->options['freemius_settings'] = array( + 'token' => '1234567890', + ); + + $scope = Scope::get_instance(); + $content = '
Original
'; + $block = array( + 'attrs' => array( + 'freemius_enabled' => true, + 'freemius' => array( + 'product_id' => 19794, + 'coupon' => 'SAVE20', + ), + ), + ); + + $result = $scope->render_scope( $content, $block, array() ); + + $this->assertStringContainsString( 'freemius-coupon-data', $result ); + $this->assertStringContainsString( 'data-freemius-coupon-code="SAVE20"', $result ); + $this->assertStringContainsString( '"discount_type":"percentage"', $result ); + } + + public function test_render_scope_deduplicates_coupon_per_product_and_code(): void { + $this->options['freemius_settings'] = array( + 'token' => '1234567890', + ); + + $scope = Scope::get_instance(); + $content = '
Block
'; + $block = array( + 'attrs' => array( + 'freemius_enabled' => true, + 'freemius' => array( + 'product_id' => 19794, + 'coupon' => 'SAVE20', + ), + ), + ); + + $first = $scope->render_scope( $content, $block, array() ); + $second = $scope->render_scope( $content, $block, array() ); + + $this->assertSame( 1, substr_count( $first, 'freemius-coupon-data' ) ); + $this->assertSame( 0, substr_count( $second, 'freemius-coupon-data' ) ); + } }