Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions includes/class-freemius-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
Expand Down Expand Up @@ -515,6 +519,7 @@ private function load_dummy_response_data(): array {
'pricing' => $pricing,
'currencies' => $currencies,
'product' => $product,
'coupons' => $coupons,
);

return $data;
Expand Down
144 changes: 144 additions & 0 deletions includes/class-freemius-coupon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php
/**
* Freemius Coupon helpers
*
* @package Freemius
* @category WordPress_Plugin
* @author Freemius <support@freemius.com>
* @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<string, mixed>|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<string, mixed> $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<int, string>
*/
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<string, mixed> $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<string, mixed> $coupon Full coupon payload.
* @return array<string, mixed>
*/
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(),
);
}
}
31 changes: 30 additions & 1 deletion includes/class-freemius-scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,19 @@ class Scope {
/**
* Whether the matrix has been added to the content
*
* @var boolean
* @var array<int|string>
*/
private $matrix_added = array();

/**
* Coupon scripts already added to the page (product_id:coupon_code).
*
* @since 0.5.0
*
* @var array<string>
*/
private $coupon_added = array();


/**
* Constructor
Expand Down Expand Up @@ -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(
'<script type="application/json" class="freemius-coupon-data" data-freemius-product-id="%1$s" data-freemius-coupon-code="%2$s">%3$s</script>',
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;
Expand Down
2 changes: 2 additions & 0 deletions includes/dummy-response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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":{}}]}';
55 changes: 40 additions & 15 deletions src/blocks/modifier/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ export {
useLicenses,
usePlans,
useProducts,
useCoupon,
};

export {
Expand Down
43 changes: 43 additions & 0 deletions src/hooks/useCoupon.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading