<?php declare(strict_types = 1);
namespace MailPoet\EmailEditor\Integrations\MailPoet\Coupons;
if (!defined('ABSPATH')) exit;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\WooCommerce\Helper;
use MailPoet\WooCommerce\RandomCouponCodeGenerator;
use MailPoet\WP\Functions as WPFunctions;
/**
* Generates coupon codes only while WooCommerce renders a MailPoet block email.
*
* Renderer clears the failure collector before each render and reads it
* immediately after rendering, so failures recorded here stop that send without
* leaking details into later renders.
*/
class CouponBlockGenerator {
const SAFE_PLACEHOLDER = CouponBlock::SAFE_PLACEHOLDER;
const MAX_CODE_RETRIES = 5;
/** @var Helper */
private $wcHelper;
/** @var CouponBlockValidator */
private $validator;
/** @var CouponBlockGenerationFailureCollector */
private $failureCollector;
/** @var WPFunctions */
private $wp;
private RandomCouponCodeGenerator $randomCouponCodeGenerator;
public function __construct(
Helper $wcHelper,
CouponBlockValidator $validator,
CouponBlockGenerationFailureCollector $failureCollector,
WPFunctions $wp,
RandomCouponCodeGenerator $randomCouponCodeGenerator
) {
$this->wcHelper = $wcHelper;
$this->validator = $validator;
$this->failureCollector = $failureCollector;
$this->wp = $wp;
$this->randomCouponCodeGenerator = $randomCouponCodeGenerator;
}
public function init(): void {
if (!class_exists(Rendering_Context::class)) {
return;
}
$this->wp->addFilter(
'woocommerce_coupon_code_block_auto_generate',
[$this, 'generate'],
5,
3
);
$this->wp->addAction('woocommerce_email_editor_render_start', [$this, 'registerEmailRenderer']);
}
public function generate($couponCode, array $attrs, Rendering_Context $renderingContext): string {
$couponCode = (string)$couponCode;
if ($couponCode !== '') {
return $couponCode;
}
$context = $renderingContext->get_email_context();
if (($context['integration'] ?? null) !== 'mailpoet') {
return $couponCode;
}
if (!CouponBlock::isCreateNew($attrs)) {
return self::SAFE_PLACEHOLDER;
}
$attrs = CouponBlock::withCreateNewDefaults($attrs);
if (!($context['is_real_send'] ?? false)) {
return self::SAFE_PLACEHOLDER;
}
$unsupportedContextMessage = $this->getUnsupportedRealSendContextMessage($attrs, $renderingContext);
if ($unsupportedContextMessage !== null) {
$this->recordFailure('invalid_real_send_context', $unsupportedContextMessage, $attrs, $context);
return self::SAFE_PLACEHOLDER;
}
if (!$this->wcHelper->isWooCommerceActive()) {
$this->recordFailure('woocommerce_unavailable', 'WooCommerce is not available.', $attrs, $context);
return self::SAFE_PLACEHOLDER;
}
try {
$recipientEmail = (string)$renderingContext->get_recipient_email();
$validatedAttrs = $this->validator->validate($attrs, $recipientEmail);
$code = $this->generateUniqueCode();
$coupon = $this->wcHelper->createWcCoupon('');
$coupon->set_code($code);
$coupon->set_description($this->getCouponDescription($context));
$coupon->set_discount_type($validatedAttrs['discountType']);
$coupon->set_amount($validatedAttrs['amount']);
if ($validatedAttrs['expiryDay'] > 0) {
$coupon->set_date_expires(time() + ($validatedAttrs['expiryDay'] * (defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400)));
}
$coupon->set_free_shipping($validatedAttrs['freeShipping']);
$coupon->set_minimum_amount($validatedAttrs['minimumAmount']);
$coupon->set_maximum_amount($validatedAttrs['maximumAmount']);
$coupon->set_individual_use($validatedAttrs['individualUse']);
$coupon->set_exclude_sale_items($validatedAttrs['excludeSaleItems']);
$coupon->set_product_ids($validatedAttrs['productIds']);
$coupon->set_excluded_product_ids($validatedAttrs['excludedProductIds']);
$coupon->set_product_categories($validatedAttrs['productCategoryIds']);
$coupon->set_excluded_product_categories($validatedAttrs['excludedProductCategoryIds']);
$coupon->set_email_restrictions($validatedAttrs['emailRestrictions']);
$coupon->set_usage_limit($validatedAttrs['usageLimit']);
$coupon->set_usage_limit_per_user($validatedAttrs['usageLimitPerUser']);
$couponId = $coupon->save();
if (!$couponId) {
throw new \RuntimeException('WooCommerce did not save the generated coupon.');
}
return $code;
} catch (\Throwable $e) {
$this->recordFailure('generation_failed', $e->getMessage(), $attrs, $context);
return self::SAFE_PLACEHOLDER;
}
}
public function registerEmailRenderer(): void {
if (!class_exists('\WP_Block_Type_Registry')) {
return;
}
$blockType = \WP_Block_Type_Registry::get_instance()->get_registered(CouponBlock::NAME);
$renderEmailCallbackProperty = 'render_email_callback';
$currentCallback = $blockType ? (get_object_vars($blockType)[$renderEmailCallbackProperty] ?? null) : null;
if (!$blockType || $this->usesWooCommerceEmailEditorRenderer($currentCallback)) {
return;
}
// WooCommerce 10.8 development builds can expose the block attributes
// without wiring the matching email renderer. Patch only that gap.
// @phpstan-ignore-next-line -- WooCommerce email editor reads this dynamic block setting.
$blockType->{$renderEmailCallbackProperty} = [$this, 'renderEmailCouponBlock'];
}
public function renderEmailCouponBlock(string $blockContent, array $parsedBlock, Rendering_Context $renderingContext): string {
$attrs = $parsedBlock['attrs'] ?? [];
if (!is_array($attrs)) {
$attrs = [];
}
if (!CouponBlock::isCreateNew($attrs, strpos($blockContent, self::SAFE_PLACEHOLDER) !== false)) {
return $blockContent;
}
$couponCode = $this->wp->applyFilters(
'woocommerce_coupon_code_block_auto_generate',
'',
$attrs,
$renderingContext
);
$couponCode = is_scalar($couponCode) ? (string)$couponCode : '';
if ($couponCode === '') {
return '';
}
return str_replace(self::SAFE_PLACEHOLDER, $this->wp->escHtml($couponCode), $blockContent);
}
private function usesWooCommerceEmailEditorRenderer($callback): bool {
if (!is_array($callback) || !isset($callback[0]) || !is_object($callback[0])) {
return false;
}
return strpos(get_class($callback[0]), 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\WooCommerce\\') === 0;
}
private function getUnsupportedRealSendContextMessage(array $attrs, Rendering_Context $renderingContext): ?string {
$requiresRecipientEmail = CouponBlockAttributeParser::toBoolean($attrs['restrictToSubscriber'] ?? false);
if ($this->isSupportedAutomationContext($renderingContext, $requiresRecipientEmail)) {
return null;
}
if ($requiresRecipientEmail) {
return 'Recipient-restricted generated coupons are only supported in automation emails sent to one subscriber at a time.';
}
if ($this->isSupportedStandardContext($renderingContext)) {
return null;
}
return 'Auto-generated coupon codes are only supported in regular newsletters and automation emails sent to one subscriber at a time.';
}
private function isSupportedAutomationContext(Rendering_Context $renderingContext, bool $requiresRecipientEmail): bool {
$context = $renderingContext->get_email_context();
$recipientEmail = $renderingContext->get_recipient_email();
$automationTypes = [
NewsletterEntity::TYPE_AUTOMATION,
NewsletterEntity::TYPE_AUTOMATION_NOTIFICATION,
NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL,
];
return ($context['integration'] ?? null) === 'mailpoet'
&& ($context['is_real_send'] ?? null) === true
&& ($context['is_preview'] ?? null) === false
&& ($context['is_single_recipient'] ?? null) === true
&& is_numeric($context['subscriber_count'] ?? null)
&& (int)$context['subscriber_count'] === 1
&& ($context['mailpoet_is_automation'] ?? null) === true
&& in_array($context['email_type'] ?? '', $automationTypes, true)
&& (
!$requiresRecipientEmail
|| (is_string($recipientEmail) && (bool)$this->wp->isEmail($recipientEmail))
);
}
private function isSupportedStandardContext(Rendering_Context $renderingContext): bool {
$context = $renderingContext->get_email_context();
return ($context['integration'] ?? null) === 'mailpoet'
&& ($context['is_real_send'] ?? null) === true
&& ($context['is_preview'] ?? null) === false
&& ($context['email_type'] ?? '') === NewsletterEntity::TYPE_STANDARD
&& ($context['mailpoet_is_automation'] ?? null) === false
&& is_numeric($context['subscriber_count'] ?? null)
&& (int)$context['subscriber_count'] >= 1;
}
private function generateUniqueCode(): string {
for ($i = 0; $i < self::MAX_CODE_RETRIES; $i++) {
$code = $this->randomCouponCodeGenerator->generate();
if (!$this->wcHelper->wcGetCouponIdByCode($code)) {
return $code;
}
}
throw new \RuntimeException('Failed to generate a unique coupon code.');
}
private function getCouponDescription(array $context): string {
return sprintf(
// translators: %1$d is the MailPoet email ID.
_x('Auto-generated coupon by MailPoet for email: %1$d', 'Coupon block code generation', 'mailpoet'),
(int)($context['newsletter_id'] ?? 0)
);
}
private function recordFailure(string $code, string $message, array $attrs, array $context): void {
$safeContext = array_intersect_key($context, array_flip([
'integration',
'newsletter_id',
'queue_id',
'email_type',
'is_real_send',
'is_preview',
'is_single_recipient',
'subscriber_count',
'mailpoet_is_automation',
]));
$this->failureCollector->record($code, $message, $attrs, $safeContext);
}
}