post_type !== self::POST_TYPE) {
return;
}
if ($new_status === $old_status) {
return;
}
if (wp_is_post_revision((int) $post->ID)) {
return;
}
// Keep hosted canonical status in sync with publish state.
$force = ($new_status === 'publish') ? 'active' : 'inactive';
self::sync_discount_to_hosted((int) $post->ID, $post, null, $force);
}
public static function on_trash_post(int $post_id): void {
$post_id = (int) $post_id;
if ($post_id <= 0) {
return;
}
$post = get_post($post_id);
if (!($post instanceof \WP_Post) || $post->post_type !== self::POST_TYPE) {
return;
}
self::sync_discount_to_hosted((int) $post->ID, $post, null, 'inactive');
}
public static function on_before_delete_post(int $post_id): void {
$post_id = (int) $post_id;
if ($post_id <= 0) {
return;
}
$post = get_post($post_id);
if (!($post instanceof \WP_Post) || $post->post_type !== self::POST_TYPE) {
return;
}
self::sync_discount_to_hosted((int) $post->ID, $post, null, 'inactive');
}
public static function enqueue_admin_styles(): void {
$screen = get_current_screen();
if ($screen && $screen->post_type === self::POST_TYPE) {
$css_ver = ToDownload_VERSION;
$css_path = ToDownload_PLUGIN_DIR . 'assets/admin-datepicker.css';
if (is_readable($css_path)) {
$mtime = filemtime($css_path);
if ($mtime) {
$css_ver = (string) $mtime;
}
}
wp_enqueue_style(
'todownload-admin-datepicker',
ToDownload_PLUGIN_URL . 'assets/admin-datepicker.css',
['wp-jquery-ui-dialog'],
$css_ver
);
$js_ver = ToDownload_VERSION;
$js_path = ToDownload_PLUGIN_DIR . 'assets/admin-discounts-metabox.js';
if (is_readable($js_path)) {
$mtime = filemtime($js_path);
if ($mtime) {
$js_ver = (string) $mtime;
}
}
wp_enqueue_script(
'todownload-admin-discounts-metabox',
ToDownload_PLUGIN_URL . 'assets/admin-discounts-metabox.js',
[],
$js_ver,
true
);
}
}
public static function register_post_type(): void {
register_post_type(self::POST_TYPE, [
'labels' => [
'name' => __('Discount Codes', '2download-connector'),
'singular_name' => __('Discount', '2download-connector'),
'menu_name' => __('Discount Code', '2download-connector'),
'add_new' => __('Add Discount', '2download-connector'),
'add_new_item' => __('Add Discount', '2download-connector'),
'edit_item' => __('Edit Discount', '2download-connector'),
'new_item' => __('New Discount', '2download-connector'),
'view_item' => __('View Discount', '2download-connector'),
'search_items' => __('Search Discounts', '2download-connector'),
'not_found' => __('No discounts found.', '2download-connector'),
'not_found_in_trash' => __('No discounts found in Trash.', '2download-connector'),
'all_items' => __('Discount Codes', '2download-connector'),
],
'public' => false,
'show_ui' => true,
'show_in_menu' => 'ToDownload_seller',
'supports' => ['title', 'author'],
'capability_type' => 'todownload_discount',
'map_meta_cap' => true,
'show_in_rest' => true,
'menu_position' => 58,
]);
}
public static function register_metaboxes(): void {
add_meta_box(
'ToDownload_discount_settings',
__('Discount Settings', '2download-connector'),
[self::class, 'render_metabox'],
self::POST_TYPE,
'normal',
'default'
);
}
private static function meta_int(int $post_id, string $key): int {
return (int) get_post_meta($post_id, $key, true);
}
private static function meta_str(int $post_id, string $key): string {
return (string) get_post_meta($post_id, $key, true);
}
public static function render_metabox($post): void {
wp_nonce_field('ToDownload_discount_save', 'ToDownload_discount_nonce');
$code = self::meta_str($post->ID, '_ToDownload_discount_code');
$apply_mode = self::meta_str($post->ID, '_ToDownload_discount_apply_mode') ?: 'code';
$type = self::meta_str($post->ID, '_ToDownload_discount_type') ?: 'percent';
$amount = self::meta_str($post->ID, '_ToDownload_discount_amount');
$min_subtotal = self::meta_str($post->ID, '_ToDownload_discount_min_subtotal');
$min_quantity = self::meta_int($post->ID, '_ToDownload_discount_min_quantity');
$max_discount = self::meta_str($post->ID, '_ToDownload_discount_max_discount');
$cart_rule = self::meta_str($post->ID, '_ToDownload_discount_cart_rule');
$rule_quantity_basis = self::meta_str($post->ID, '_ToDownload_discount_rule_quantity_basis') ?: 'eligible';
$tier_rules_text = self::meta_str($post->ID, '_ToDownload_discount_tier_rules_text');
$cheapest_item_quantity = self::meta_int($post->ID, '_ToDownload_discount_cheapest_item_quantity');
$cheapest_item_percent_off = self::meta_str($post->ID, '_ToDownload_discount_cheapest_item_percent_off');
$bundle_set_quantity = self::meta_int($post->ID, '_ToDownload_discount_bundle_set_quantity');
$bogo_buy_quantity = self::meta_int($post->ID, '_ToDownload_discount_bogo_buy_quantity');
$bogo_get_quantity = self::meta_int($post->ID, '_ToDownload_discount_bogo_get_quantity');
$bogo_get_percent_off = self::meta_str($post->ID, '_ToDownload_discount_bogo_get_percent_off');
$product_ids = self::meta_str($post->ID, '_ToDownload_discount_product_ids');
$selected_ids = self::parse_ids_csv($product_ids);
$excluded_product_ids = self::meta_str($post->ID, '_ToDownload_discount_excluded_product_ids');
$selected_excluded_ids = self::parse_ids_csv($excluded_product_ids);
$required_prior_product_ids = self::meta_str($post->ID, '_ToDownload_discount_required_prior_product_ids');
$selected_required_prior_ids = self::parse_ids_csv($required_prior_product_ids);
$allowed_customer_emails = self::meta_str($post->ID, '_ToDownload_discount_allowed_customer_emails');
$usage_limit = self::meta_int($post->ID, '_ToDownload_discount_usage_limit');
$usage_limit_per_customer = self::meta_int($post->ID, '_ToDownload_discount_usage_limit_per_customer');
$usage_count = self::meta_int($post->ID, '_ToDownload_discount_usage_count');
$expires = self::meta_str($post->ID, '_ToDownload_discount_expires');
$returning_only = self::meta_int($post->ID, '_ToDownload_discount_returning_customers_only') === 1;
$first_time_only = self::meta_int($post->ID, '_ToDownload_discount_first_time_customers_only') === 1;
$eligibility = $returning_only ? 'returning' : ($first_time_only ? 'first_time' : 'any');
?>
:
$id > 0)));
$product_ids = implode(',', $ids);
} else {
$product_ids = sanitize_text_field((string) $product_ids_raw);
}
$usage_limit = isset($post_data['ToDownload_discount_usage_limit']) ? absint($post_data['ToDownload_discount_usage_limit']) : 0;
$usage_limit_per_customer = isset($post_data['ToDownload_discount_usage_limit_per_customer']) ? absint($post_data['ToDownload_discount_usage_limit_per_customer']) : 0;
$eligibility = sanitize_text_field((string) ($post_data['ToDownload_discount_customer_eligibility'] ?? 'any'));
$expires = sanitize_text_field((string) ($post_data['ToDownload_discount_expires'] ?? ''));
$excluded_product_ids_raw = $post_data['ToDownload_discount_excluded_product_ids'] ?? '';
$excluded_product_ids = '';
if (is_array($excluded_product_ids_raw)) {
$ids = array_values(array_filter(array_map('intval', $excluded_product_ids_raw)));
$ids = array_values(array_unique(array_filter($ids, fn($id) => $id > 0)));
$excluded_product_ids = implode(',', $ids);
} else {
$excluded_product_ids = sanitize_text_field((string) $excluded_product_ids_raw);
}
$required_prior_product_ids_raw = $post_data['ToDownload_discount_required_prior_product_ids'] ?? '';
$required_prior_product_ids = '';
if (is_array($required_prior_product_ids_raw)) {
$ids = array_values(array_filter(array_map('intval', $required_prior_product_ids_raw)));
$ids = array_values(array_unique(array_filter($ids, fn($id) => $id > 0)));
$required_prior_product_ids = implode(',', $ids);
} else {
$required_prior_product_ids = sanitize_text_field((string) $required_prior_product_ids_raw);
}
$allowed_customer_emails = isset($post_data['ToDownload_discount_allowed_customer_emails'])
? sanitize_textarea_field((string) $post_data['ToDownload_discount_allowed_customer_emails'])
: '';
update_post_meta($post_id, '_ToDownload_discount_code', $code);
update_post_meta($post_id, '_ToDownload_discount_apply_mode', in_array($apply_mode, ['code', 'auto'], true) ? $apply_mode : 'code');
update_post_meta($post_id, '_ToDownload_discount_type', in_array($type, ['percent', 'flat', 'fixed_price', 'bogo'], true) ? $type : 'percent');
update_post_meta($post_id, '_ToDownload_discount_amount', $amount);
update_post_meta($post_id, '_ToDownload_discount_min_subtotal', $min_subtotal);
update_post_meta($post_id, '_ToDownload_discount_min_quantity', $min_quantity);
update_post_meta($post_id, '_ToDownload_discount_max_discount', $max_discount);
update_post_meta($post_id, '_ToDownload_discount_bogo_buy_quantity', $bogo_buy_quantity);
update_post_meta($post_id, '_ToDownload_discount_bogo_get_quantity', $bogo_get_quantity);
update_post_meta($post_id, '_ToDownload_discount_bogo_get_percent_off', $bogo_get_percent_off);
update_post_meta($post_id, '_ToDownload_discount_cart_rule', in_array($cart_rule, ['tiered', 'cheapest', 'bundle'], true) ? $cart_rule : '');
update_post_meta($post_id, '_ToDownload_discount_rule_quantity_basis', in_array($rule_quantity_basis, ['eligible', 'total'], true) ? $rule_quantity_basis : 'eligible');
update_post_meta($post_id, '_ToDownload_discount_tier_rules_text', $tier_rules_text);
update_post_meta($post_id, '_ToDownload_discount_cheapest_item_quantity', $cheapest_item_quantity);
update_post_meta($post_id, '_ToDownload_discount_cheapest_item_percent_off', $cheapest_item_percent_off);
update_post_meta($post_id, '_ToDownload_discount_bundle_set_quantity', $bundle_set_quantity);
update_post_meta($post_id, '_ToDownload_discount_product_ids', $product_ids);
update_post_meta($post_id, '_ToDownload_discount_excluded_product_ids', $excluded_product_ids);
update_post_meta($post_id, '_ToDownload_discount_required_prior_product_ids', $required_prior_product_ids);
update_post_meta($post_id, '_ToDownload_discount_allowed_customer_emails', $allowed_customer_emails);
update_post_meta($post_id, '_ToDownload_discount_usage_limit', $usage_limit);
update_post_meta($post_id, '_ToDownload_discount_usage_limit_per_customer', $usage_limit_per_customer);
update_post_meta($post_id, '_ToDownload_discount_returning_customers_only', $eligibility === 'returning' ? 1 : 0);
update_post_meta($post_id, '_ToDownload_discount_first_time_customers_only', $eligibility === 'first_time' ? 1 : 0);
update_post_meta($post_id, '_ToDownload_discount_expires', $expires);
// Sync to hosted platform so 2dl.app is the canonical discount system.
self::sync_discount_to_hosted((int) $post_id, $post, [
'code' => $code,
'apply_mode' => $apply_mode,
'type' => $type,
'amount' => $amount,
'min_subtotal' => $min_subtotal,
'min_quantity' => (string) $min_quantity,
'max_discount' => $max_discount,
'bogo_buy_quantity' => (string) $bogo_buy_quantity,
'bogo_get_quantity' => (string) $bogo_get_quantity,
'bogo_get_percent_off' => $bogo_get_percent_off,
'cart_rule' => $cart_rule,
'rule_quantity_basis' => $rule_quantity_basis,
'tier_rules_text' => $tier_rules_text,
'cheapest_item_quantity' => $cheapest_item_quantity,
'cheapest_item_percent_off' => $cheapest_item_percent_off,
'bundle_set_quantity' => $bundle_set_quantity,
'usage_limit' => $usage_limit,
'usage_limit_per_customer' => $usage_limit_per_customer,
'customer_eligibility' => $eligibility,
'expires' => $expires,
], null);
}
private static function parse_emails_text(string $raw): array {
$raw = trim($raw);
if ($raw === '') {
return [];
}
$raw = str_replace("\r", "\n", $raw);
$parts = preg_split('/\n|,/', $raw);
if (!is_array($parts)) {
return [];
}
$out = [];
$seen = [];
foreach ($parts as $p) {
$e = strtolower(trim((string) $p));
if ($e === '' || !is_email($e)) {
continue;
}
if (isset($seen[$e])) {
continue;
}
$seen[$e] = true;
$out[] = $e;
if (count($out) >= 500) {
break;
}
}
return $out;
}
/**
* @param int[] $wp_download_ids
* @return array Map of wp_download_id => hosted product publicId
*/
private static function hosted_public_id_map_for_wp_download_ids(string $hosted_base, string $store_public_id, string $api_key, string $request_id, array $wp_download_ids): array {
$wp_download_ids = array_values(array_unique(array_filter(array_map('intval', $wp_download_ids), fn($id) => $id > 0)));
if (empty($wp_download_ids)) {
return [];
}
$hosted_products = [];
$products_res = HostedClient::get_json(
$hosted_base,
'/api/v1/stores/' . rawurlencode($store_public_id) . '/products',
$api_key,
$request_id
);
if (!empty($products_res['ok']) && isset($products_res['data']) && is_array($products_res['data'])) {
$hosted_products = is_array($products_res['data']['products'] ?? null) ? (array) $products_res['data']['products'] : [];
}
$external_to_public = [];
foreach ($hosted_products as $p) {
if (!is_array($p)) {
continue;
}
$ext = isset($p['externalId']) ? (string) $p['externalId'] : '';
$pub = isset($p['publicId']) ? (string) $p['publicId'] : '';
$ext = trim($ext);
$pub = trim($pub);
if ($ext !== '' && $pub !== '') {
$external_to_public[$ext] = $pub;
}
}
$out = [];
foreach ($wp_download_ids as $wp_download_id) {
$wp_download_id = (int) $wp_download_id;
$external_id = 'wp_download_' . (string) $wp_download_id;
if (isset($external_to_public[$external_id])) {
$out[$wp_download_id] = (string) $external_to_public[$external_id];
continue;
}
$title = get_the_title($wp_download_id);
$name = is_string($title) && trim($title) !== '' ? trim($title) : ('WP Download #' . (string) $wp_download_id);
$price_raw = (string) get_post_meta($wp_download_id, '_ToDownload_price', true);
$price = (float) $price_raw;
$amount_minor = (int) round(max(0.0, $price) * 100);
if ($amount_minor <= 0) {
continue;
}
$create_res = HostedClient::post_json(
$hosted_base,
'/api/v1/stores/' . rawurlencode($store_public_id) . '/products',
[
'external_id' => $external_id,
'name' => $name,
'currency' => 'usd',
'amount_minor' => $amount_minor,
'status' => 'active',
],
$api_key,
$request_id
);
if (!empty($create_res['ok']) && isset($create_res['product']) && is_array($create_res['product'])) {
$created = (array) $create_res['product'];
$pub = isset($created['public_id']) ? trim((string) $created['public_id']) : '';
if ($pub !== '') {
$out[$wp_download_id] = $pub;
$external_to_public[$external_id] = $pub;
}
}
}
return $out;
}
/**
* @param array{code:string,apply_mode?:string,type:string,amount:string,min_subtotal?:string,min_quantity?:string,max_discount?:string,bogo_buy_quantity?:string,bogo_get_quantity?:string,bogo_get_percent_off?:string,cart_rule?:string,rule_quantity_basis?:string,tier_rules_text?:string,cheapest_item_quantity?:int,cheapest_item_percent_off?:string,bundle_set_quantity?:int,usage_limit:int,usage_limit_per_customer?:int,customer_eligibility?:string,expires:string}|null $data
*/
private static function sync_discount_to_hosted(int $post_id, \WP_Post $post, ?array $data, ?string $force_status): void {
$code = '';
if (is_array($data) && isset($data['code'])) {
$code = strtoupper(trim((string) $data['code']));
}
if ($code === '') {
$code = (string) get_post_meta($post_id, '_ToDownload_discount_code', true);
$code = strtoupper(trim($code));
}
if ($code === '') {
return;
}
$seller_user_id = (int) ($post->post_author ?? 0);
if ($seller_user_id <= 0) {
return;
}
$hosted = Sellers::hosted_credentials($seller_user_id);
if (empty($hosted['connected']) || empty($hosted['store_public_id']) || empty($hosted['api_key'])) {
return;
}
$hosted_base = (string) ($hosted['hosted_base'] ?? 'https://2dl.app');
$hosted_base = $hosted_base !== '' ? rtrim($hosted_base, '/') : 'https://2dl.app';
$store_public_id = trim((string) ($hosted['store_public_id'] ?? ''));
$api_key = trim((string) ($hosted['api_key'] ?? ''));
if ($store_public_id === '' || $api_key === '') {
return;
}
$apply_mode = is_array($data) ? (string) ($data['apply_mode'] ?? '') : '';
$type = is_array($data) ? (string) ($data['type'] ?? '') : '';
$amount = is_array($data) ? (string) ($data['amount'] ?? '') : '';
$min_subtotal = is_array($data) ? (string) ($data['min_subtotal'] ?? '') : '';
$min_quantity = is_array($data) ? (string) ($data['min_quantity'] ?? '') : '';
$max_discount = is_array($data) ? (string) ($data['max_discount'] ?? '') : '';
$bogo_buy_quantity = is_array($data) ? (string) ($data['bogo_buy_quantity'] ?? '') : '';
$bogo_get_quantity = is_array($data) ? (string) ($data['bogo_get_quantity'] ?? '') : '';
$bogo_get_percent_off = is_array($data) ? (string) ($data['bogo_get_percent_off'] ?? '') : '';
$cart_rule = is_array($data) ? (string) ($data['cart_rule'] ?? '') : '';
$rule_quantity_basis = is_array($data) ? (string) ($data['rule_quantity_basis'] ?? '') : '';
$tier_rules_text = is_array($data) ? (string) ($data['tier_rules_text'] ?? '') : '';
$cheapest_item_quantity = is_array($data) ? (int) ($data['cheapest_item_quantity'] ?? 0) : 0;
$cheapest_item_percent_off = is_array($data) ? (string) ($data['cheapest_item_percent_off'] ?? '') : '';
$bundle_set_quantity = is_array($data) ? (int) ($data['bundle_set_quantity'] ?? 0) : 0;
$usage_limit = is_array($data) ? (int) ($data['usage_limit'] ?? 0) : 0;
$usage_limit_per_customer = is_array($data) ? (int) ($data['usage_limit_per_customer'] ?? 0) : 0;
$customer_eligibility = is_array($data) ? (string) ($data['customer_eligibility'] ?? '') : '';
$expires = is_array($data) ? trim((string) ($data['expires'] ?? '')) : '';
if ($type === '' || $amount === '') {
$apply_mode = (string) get_post_meta($post_id, '_ToDownload_discount_apply_mode', true);
$type = (string) get_post_meta($post_id, '_ToDownload_discount_type', true);
$amount = (string) get_post_meta($post_id, '_ToDownload_discount_amount', true);
$min_subtotal = (string) get_post_meta($post_id, '_ToDownload_discount_min_subtotal', true);
$min_quantity = (string) get_post_meta($post_id, '_ToDownload_discount_min_quantity', true);
$max_discount = (string) get_post_meta($post_id, '_ToDownload_discount_max_discount', true);
$bogo_buy_quantity = (string) get_post_meta($post_id, '_ToDownload_discount_bogo_buy_quantity', true);
$bogo_get_quantity = (string) get_post_meta($post_id, '_ToDownload_discount_bogo_get_quantity', true);
$bogo_get_percent_off = (string) get_post_meta($post_id, '_ToDownload_discount_bogo_get_percent_off', true);
$cart_rule = (string) get_post_meta($post_id, '_ToDownload_discount_cart_rule', true);
$rule_quantity_basis = (string) get_post_meta($post_id, '_ToDownload_discount_rule_quantity_basis', true);
$tier_rules_text = (string) get_post_meta($post_id, '_ToDownload_discount_tier_rules_text', true);
$cheapest_item_quantity = (int) get_post_meta($post_id, '_ToDownload_discount_cheapest_item_quantity', true);
$cheapest_item_percent_off = (string) get_post_meta($post_id, '_ToDownload_discount_cheapest_item_percent_off', true);
$bundle_set_quantity = (int) get_post_meta($post_id, '_ToDownload_discount_bundle_set_quantity', true);
$usage_limit = (int) get_post_meta($post_id, '_ToDownload_discount_usage_limit', true);
$usage_limit_per_customer = (int) get_post_meta($post_id, '_ToDownload_discount_usage_limit_per_customer', true);
$customer_eligibility = (string) get_post_meta($post_id, '_ToDownload_discount_returning_customers_only', true) === '1'
? 'returning'
: (((string) get_post_meta($post_id, '_ToDownload_discount_first_time_customers_only', true) === '1') ? 'first_time' : 'any');
$expires = (string) get_post_meta($post_id, '_ToDownload_discount_expires', true);
}
if (!in_array($apply_mode, ['code', 'auto'], true)) {
$apply_mode = 'code';
}
if (!in_array($type, ['percent', 'flat', 'fixed_price', 'bogo'], true)) {
$type = 'percent';
}
$amount_f = (float) $amount;
$min_subtotal_f = (float) $min_subtotal;
$min_quantity_i = (int) $min_quantity;
$max_discount_f = (float) $max_discount;
$bogo_buy_i = (int) $bogo_buy_quantity;
$bogo_get_i = (int) $bogo_get_quantity;
$bogo_pct_f = (float) $bogo_get_percent_off;
$request_id = function_exists('wp_generate_uuid4') ? (string) wp_generate_uuid4() : (string) uniqid('2dl_', true);
$payload = [
'code' => $code,
'status' => ($force_status === 'active' || $force_status === 'inactive')
? $force_status
: (($post->post_status === 'publish') ? 'active' : 'inactive'),
'apply_mode' => $apply_mode,
];
// Product targeting: sync WP download selections to hosted product_public_ids.
// We mirror WP downloads as hosted Products keyed by external_id = wp_download_{POST_ID}.
$include_ids = self::parse_ids_csv((string) get_post_meta($post_id, '_ToDownload_discount_product_ids', true));
$exclude_ids = self::parse_ids_csv((string) get_post_meta($post_id, '_ToDownload_discount_excluded_product_ids', true));
$required_prior_ids = self::parse_ids_csv((string) get_post_meta($post_id, '_ToDownload_discount_required_prior_product_ids', true));
$all = array_values(array_unique(array_merge($include_ids, $exclude_ids, $required_prior_ids)));
$map = !empty($all) ? self::hosted_public_id_map_for_wp_download_ids($hosted_base, $store_public_id, $api_key, $request_id, $all) : [];
$payload['product_public_ids'] = array_values(array_unique(array_values(array_filter(array_map(fn($id) => $map[(int) $id] ?? '', $include_ids)))));
$payload['excluded_product_public_ids'] = array_values(array_unique(array_values(array_filter(array_map(fn($id) => $map[(int) $id] ?? '', $exclude_ids)))));
$payload['required_prior_product_public_ids'] = array_values(array_unique(array_values(array_filter(array_map(fn($id) => $map[(int) $id] ?? '', $required_prior_ids)))));
if ($type === 'bogo') {
$payload['type'] = 'bogo';
$payload['bogo_buy_quantity'] = max(1, $bogo_buy_i);
$payload['bogo_get_quantity'] = max(1, $bogo_get_i);
$payload['bogo_get_percent_off_bps'] = (int) round(max(0.0, min(100.0, $bogo_pct_f > 0 ? $bogo_pct_f : 100.0)) * 100);
} else if ($type === 'flat') {
$payload['type'] = 'amount';
$payload['amount_off_minor'] = max(0, (int) round(max(0.0, $amount_f) * 100));
} else if ($type === 'fixed_price') {
$payload['type'] = 'fixed_price';
$payload['fixed_price_minor'] = max(0, (int) round(max(0.0, $amount_f) * 100));
} else {
$payload['type'] = 'percent';
$percent = max(0.0, min(100.0, $amount_f));
$payload['percent_off_bps'] = (int) round($percent * 100);
}
if ($min_subtotal !== '') {
$payload['min_subtotal_minor'] = max(0, (int) round(max(0.0, $min_subtotal_f) * 100));
}
if ($min_quantity_i > 0) {
$payload['min_quantity'] = $min_quantity_i;
}
if ($max_discount !== '') {
$payload['max_discount_minor'] = max(0, (int) round(max(0.0, $max_discount_f) * 100));
}
if ($usage_limit > 0) {
$payload['usage_limit_total'] = $usage_limit;
}
if ($usage_limit_per_customer > 0) {
$payload['usage_limit_per_customer'] = $usage_limit_per_customer;
}
if ($customer_eligibility === 'returning') {
$payload['returning_customers_only'] = true;
$payload['first_time_customers_only'] = false;
} else if ($customer_eligibility === 'first_time') {
$payload['returning_customers_only'] = false;
$payload['first_time_customers_only'] = true;
}
if ($expires !== '') {
try {
$dt = new \DateTimeImmutable($expires . ' 23:59:59', new \DateTimeZone('UTC'));
$payload['ends_at'] = $dt->format('c');
} catch (\Exception $e) {
// ignore invalid date
}
}
// Cart rules (tiered / cheapest / bundle). These are optional and cannot be combined with BOGO.
$cart_rule = trim((string) $cart_rule);
if (!in_array($cart_rule, ['tiered', 'cheapest', 'bundle'], true)) {
$cart_rule = '';
}
$rule_quantity_basis = trim((string) $rule_quantity_basis);
if (!in_array($rule_quantity_basis, ['eligible', 'total'], true)) {
$rule_quantity_basis = 'eligible';
}
if ($cart_rule !== '' && ($payload['type'] ?? '') !== 'bogo') {
if ($cart_rule === 'tiered' && in_array((string) ($payload['type'] ?? ''), ['percent', 'amount'], true)) {
$tiers = self::parse_tier_rules_text($tier_rules_text, (string) ($payload['type'] ?? ''));
if (!empty($tiers)) {
$payload['cart_rule'] = 'tiered';
$payload['rule_quantity_basis'] = $rule_quantity_basis;
$payload['tier_rules'] = $tiers;
}
} else if ($cart_rule === 'cheapest' && (string) ($payload['type'] ?? '') === 'percent') {
$qty = max(0, (int) $cheapest_item_quantity);
$pct = (float) $cheapest_item_percent_off;
$bps = (int) round(max(0.0, min(100.0, $pct)) * 100);
if ($qty > 0 && $bps > 0) {
$payload['cart_rule'] = 'cheapest';
$payload['rule_quantity_basis'] = $rule_quantity_basis;
$payload['cheapest_item_quantity'] = $qty;
$payload['cheapest_item_percent_off_bps'] = $bps;
}
} else if ($cart_rule === 'bundle' && (string) ($payload['type'] ?? '') === 'fixed_price') {
$set_qty = max(0, (int) $bundle_set_quantity);
if ($set_qty > 0) {
$payload['cart_rule'] = 'bundle';
$payload['rule_quantity_basis'] = $rule_quantity_basis;
$payload['bundle_set_quantity'] = $set_qty;
}
}
}
$emails_raw = (string) get_post_meta($post_id, '_ToDownload_discount_allowed_customer_emails', true);
$emails = self::parse_emails_text($emails_raw);
if (!empty($emails)) {
$payload['allowed_customer_emails'] = $emails;
}
$res = HostedClient::post_json(
$hosted_base,
'/api/v1/stores/' . rawurlencode($store_public_id) . '/discounts',
$payload,
$api_key,
$request_id
);
if (!empty($res['ok']) && isset($res['data']) && is_array($res['data'])) {
$d = (array) ($res['data']['discount'] ?? []);
$public_id = isset($d['publicId']) ? sanitize_text_field((string) $d['publicId']) : '';
if ($public_id !== '') {
update_post_meta($post_id, '_ToDownload_hosted_discount_public_id', $public_id);
}
delete_post_meta($post_id, '_ToDownload_hosted_discount_sync_error');
return;
}
$msg = isset($res['message']) ? (string) $res['message'] : '';
if ($msg === '' && isset($res['error']) && is_array($res['error'])) {
$msg = isset($res['error']['message']) ? (string) $res['error']['message'] : '';
}
if ($msg !== '') {
update_post_meta($post_id, '_ToDownload_hosted_discount_sync_error', sanitize_text_field($msg));
}
}
/**
* @return array
*/
private static function parse_tier_rules_text(string $raw, string $hosted_type): array {
$raw = trim((string) $raw);
if ($raw === '') {
return [];
}
$hosted_type = trim((string) $hosted_type);
if (!in_array($hosted_type, ['percent', 'amount'], true)) {
return [];
}
$raw = str_replace("\r", "\n", $raw);
$lines = explode("\n", $raw);
$by_qty = [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
// Format: "min_qty: value" (e.g. "2: 10")
if (!preg_match('/^\s*(\d+)\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*$/', $line, $m)) {
continue;
}
$min_qty = max(0, (int) $m[1]);
$val = (float) $m[2];
if ($min_qty <= 0) {
continue;
}
if ($hosted_type === 'percent') {
$bps = (int) round(max(0.0, min(100.0, $val)) * 100);
if ($bps <= 0) {
continue;
}
$by_qty[$min_qty] = ['min_quantity' => $min_qty, 'percent_off_bps' => $bps];
} else {
$minor = (int) round(max(0.0, $val) * 100);
if ($minor <= 0) {
continue;
}
$by_qty[$min_qty] = ['min_quantity' => $min_qty, 'amount_off_minor' => $minor];
}
}
if (empty($by_qty)) {
return [];
}
ksort($by_qty);
return array_values($by_qty);
}
public static function apply_discount_code(string $code, array $cart_items, float $subtotal, int $seller_user_id = 0, ?string $buyer_email = null): array {
$code = strtoupper(trim($code));
if ($code === '') {
return ['ok' => true, 'discount' => 0.0, 'code' => ''];
}
$seller_user_id = (int) $seller_user_id;
if ($seller_user_id <= 0) {
foreach ($cart_items as $it) {
$download_id = (int) ($it['download_id'] ?? 0);
if ($download_id > 0) {
$seller_user_id = (int) get_post_field('post_author', $download_id);
break;
}
}
}
$discount_post_id = self::find_discount_post_id($code, $seller_user_id);
if (!$discount_post_id) {
return ['ok' => false, 'message' => __('Invalid discount code.', '2download-connector')];
}
$expires = (string) get_post_meta($discount_post_id, '_ToDownload_discount_expires', true);
if ($expires !== '') {
$ts = strtotime($expires . ' 23:59:59');
if ($ts && time() > $ts) {
return ['ok' => false, 'message' => __('This discount code has expired.', '2download-connector')];
}
}
$usage_limit = (int) get_post_meta($discount_post_id, '_ToDownload_discount_usage_limit', true);
$usage_count = (int) get_post_meta($discount_post_id, '_ToDownload_discount_usage_count', true);
if ($usage_limit > 0 && $usage_count >= $usage_limit) {
return ['ok' => false, 'message' => __('This discount code has reached its usage limit.', '2download-connector')];
}
$buyer_email = sanitize_email((string) $buyer_email);
$usage_limit_per_customer = (int) get_post_meta($discount_post_id, '_ToDownload_discount_usage_limit_per_customer', true);
$returning_only = (string) get_post_meta($discount_post_id, '_ToDownload_discount_returning_customers_only', true) === '1';
$first_time_only = (string) get_post_meta($discount_post_id, '_ToDownload_discount_first_time_customers_only', true) === '1';
if (($usage_limit_per_customer > 0 || $returning_only || $first_time_only) && ($buyer_email === '' || !is_email($buyer_email))) {
return ['ok' => false, 'message' => __('Please enter a valid email to use this discount code.', '2download-connector')];
}
if ($buyer_email !== '' && is_email($buyer_email)) {
$prior_complete_for_seller = 0;
$prior_complete_with_code = 0;
$orders = Orders::find_orders_by_email($buyer_email);
foreach ($orders as $o) {
if (!is_array($o)) {
continue;
}
if ((string) ($o['status'] ?? '') !== 'complete') {
continue;
}
if ((int) ($o['seller_user_id'] ?? 0) !== (int) $seller_user_id) {
continue;
}
$prior_complete_for_seller++;
$notes = (string) ($o['notes'] ?? '');
if ($notes !== '' && strpos($notes, 'ToDownload_discount_code: ' . $code) !== false) {
$prior_complete_with_code++;
}
}
if ($returning_only && $prior_complete_for_seller <= 0) {
return ['ok' => false, 'message' => __('This discount code is only for returning customers.', '2download-connector')];
}
if ($first_time_only && $prior_complete_for_seller > 0) {
return ['ok' => false, 'message' => __('This discount code is only for first-time customers.', '2download-connector')];
}
if ($usage_limit_per_customer > 0 && $prior_complete_with_code >= $usage_limit_per_customer) {
return ['ok' => false, 'message' => __('This discount code has reached its per-customer limit.', '2download-connector')];
}
}
$product_ids_raw = (string) get_post_meta($discount_post_id, '_ToDownload_discount_product_ids', true);
$allowed_ids = self::parse_ids_csv($product_ids_raw);
if (!empty($allowed_ids)) {
$cart_ids = array_map(fn($it) => (int) ($it['download_id'] ?? 0), $cart_items);
$matches = array_intersect($allowed_ids, $cart_ids);
if (empty($matches)) {
return ['ok' => false, 'message' => __('This discount code does not apply to items in your cart.', '2download-connector')];
}
}
$type = (string) get_post_meta($discount_post_id, '_ToDownload_discount_type', true);
$amount = (float) get_post_meta($discount_post_id, '_ToDownload_discount_amount', true);
$min_quantity = (int) get_post_meta($discount_post_id, '_ToDownload_discount_min_quantity', true);
$max_discount = (string) get_post_meta($discount_post_id, '_ToDownload_discount_max_discount', true);
$cart_rule = (string) get_post_meta($discount_post_id, '_ToDownload_discount_cart_rule', true);
$rule_quantity_basis = (string) get_post_meta($discount_post_id, '_ToDownload_discount_rule_quantity_basis', true);
$tier_rules_text = (string) get_post_meta($discount_post_id, '_ToDownload_discount_tier_rules_text', true);
$cheapest_item_quantity = (int) get_post_meta($discount_post_id, '_ToDownload_discount_cheapest_item_quantity', true);
$cheapest_item_percent_off = (float) get_post_meta($discount_post_id, '_ToDownload_discount_cheapest_item_percent_off', true);
$bundle_set_quantity = (int) get_post_meta($discount_post_id, '_ToDownload_discount_bundle_set_quantity', true);
$excluded_ids_raw = (string) get_post_meta($discount_post_id, '_ToDownload_discount_excluded_product_ids', true);
$excluded_ids = self::parse_ids_csv($excluded_ids_raw);
$total_unit_amounts_minor = [];
$eligible_unit_amounts_minor = [];
foreach ($cart_items as $it) {
$download_id = (int) ($it['download_id'] ?? 0);
if ($download_id <= 0) {
continue;
}
$price = isset($it['price']) ? (float) $it['price'] : 0.0;
$minor = (int) round(max(0.0, $price) * 100);
if ($minor <= 0) {
continue;
}
$total_unit_amounts_minor[] = $minor;
if (!empty($excluded_ids) && in_array($download_id, $excluded_ids, true)) {
continue;
}
if (!empty($allowed_ids) && !in_array($download_id, $allowed_ids, true)) {
continue;
}
$eligible_unit_amounts_minor[] = $minor;
}
$eligible_subtotal_minor = array_sum($eligible_unit_amounts_minor);
$total_count = count($total_unit_amounts_minor);
$eligible_count = count($eligible_unit_amounts_minor);
$basis = in_array($rule_quantity_basis, ['eligible', 'total'], true) ? $rule_quantity_basis : 'eligible';
$basis_count = $basis === 'total' ? $total_count : $eligible_count;
if ($min_quantity > 0 && $eligible_count < $min_quantity) {
return ['ok' => false, 'message' => __('Your cart does not meet the minimum quantity for this discount code.', '2download-connector')];
}
$discount = 0.0;
$cart_rule = in_array($cart_rule, ['tiered', 'cheapest', 'bundle'], true) ? $cart_rule : '';
if ($type === 'bogo') {
$buy_qty = (int) get_post_meta($discount_post_id, '_ToDownload_discount_bogo_buy_quantity', true);
$get_qty = (int) get_post_meta($discount_post_id, '_ToDownload_discount_bogo_get_quantity', true);
$pct = (float) get_post_meta($discount_post_id, '_ToDownload_discount_bogo_get_percent_off', true);
$buy_qty = max(1, $buy_qty);
$get_qty = max(1, $get_qty);
$set_size = $buy_qty + $get_qty;
$sets = (int) floor(count($eligible_unit_amounts_minor) / $set_size);
$discount_units = $sets * $get_qty;
if ($discount_units <= 0) {
return ['ok' => false, 'message' => __('This discount code does not apply to items in your cart.', '2download-connector')];
}
$pct = max(0.0, min(100.0, $pct > 0 ? $pct : 100.0));
$bps = (int) round($pct * 100);
sort($eligible_unit_amounts_minor);
$chosen = array_slice($eligible_unit_amounts_minor, 0, $discount_units);
$discount_minor = 0;
foreach ($chosen as $unit_minor) {
$discount_minor += (int) floor(((int) $unit_minor * $bps) / 10000);
}
$discount = min($subtotal, max(0.0, ((float) $discount_minor) / 100.0));
} else if ($cart_rule === 'tiered' && ($type === 'percent' || $type === 'flat')) {
$hosted_type = $type === 'flat' ? 'amount' : 'percent';
$tiers = self::parse_tier_rules_text($tier_rules_text, $hosted_type);
if (empty($tiers)) {
return ['ok' => false, 'message' => __('This discount code does not apply to items in your cart.', '2download-connector')];
}
$chosen = null;
foreach ($tiers as $tr) {
$minq = (int) ($tr['min_quantity'] ?? 0);
if ($minq > 0 && $minq <= $basis_count) {
$chosen = $tr;
}
}
if (!$chosen) {
return ['ok' => false, 'message' => __('This discount code does not apply to items in your cart.', '2download-connector')];
}
if ($hosted_type === 'percent') {
$bps = (int) ($chosen['percent_off_bps'] ?? 0);
$discount_minor = (int) floor(((int) $eligible_subtotal_minor * $bps) / 10000);
$discount = min($subtotal, max(0.0, ((float) $discount_minor) / 100.0));
} else {
$minor = (int) ($chosen['amount_off_minor'] ?? 0);
$discount = min($subtotal, max(0.0, min(((float) $eligible_subtotal_minor) / 100.0, ((float) $minor) / 100.0)));
}
} else if ($cart_rule === 'cheapest' && $type === 'percent') {
$qty = max(0, (int) $cheapest_item_quantity);
$bps = (int) round(max(0.0, min(100.0, $cheapest_item_percent_off)) * 100);
if ($qty <= 0 || $bps <= 0 || $eligible_count < $qty) {
return ['ok' => false, 'message' => __('This discount code does not apply to items in your cart.', '2download-connector')];
}
sort($eligible_unit_amounts_minor);
$chosen_units = array_slice($eligible_unit_amounts_minor, 0, $qty);
$discount_minor = 0;
foreach ($chosen_units as $unit_minor) {
$discount_minor += (int) floor(((int) $unit_minor * $bps) / 10000);
}
$discount = min($subtotal, max(0.0, ((float) $discount_minor) / 100.0));
} else if ($cart_rule === 'bundle' && $type === 'fixed_price') {
$set_qty = max(0, (int) $bundle_set_quantity);
$sets = $set_qty > 0 ? (int) floor($basis_count / $set_qty) : 0;
if ($set_qty <= 0 || $sets <= 0 || $eligible_count < ($sets * $set_qty)) {
return ['ok' => false, 'message' => __('This discount code does not apply to items in your cart.', '2download-connector')];
}
$fixed_price_minor = (int) round(max(0.0, $amount) * 100);
rsort($eligible_unit_amounts_minor);
$take = $sets * $set_qty;
$bundle_units = array_slice($eligible_unit_amounts_minor, 0, $take);
$bundle_subtotal_minor = array_sum($bundle_units);
$discount_minor = max(0, $bundle_subtotal_minor - ($sets * $fixed_price_minor));
$discount = min($subtotal, max(0.0, ((float) $discount_minor) / 100.0));
} else if ($type === 'flat') {
$discount = min($subtotal, max(0.0, min(((float) $eligible_subtotal_minor) / 100.0, $amount)));
} else if ($type === 'fixed_price') {
$fixed_price_minor = (int) round(max(0.0, $amount) * 100);
$discount_minor = max(0, $eligible_subtotal_minor - $fixed_price_minor);
$discount = min($subtotal, max(0.0, ((float) $discount_minor) / 100.0));
} else {
$percent = max(0.0, min(100.0, $amount));
$discount = (((float) $eligible_subtotal_minor) / 100.0) * ($percent / 100.0);
$discount = min($subtotal, max(0.0, $discount));
}
if ($max_discount !== '') {
$cap = (float) $max_discount;
if ($cap > 0) {
$discount = min($discount, $cap);
}
}
return [
'ok' => true,
'code' => $code,
'discount' => (float) round($discount, 2),
'discount_post_id' => $discount_post_id,
];
}
private static function parse_ids_csv(string $csv): array {
$csv = trim($csv);
if ($csv === '') {
return [];
}
$parts = array_filter(array_map('trim', explode(',', $csv)));
$out = [];
foreach ($parts as $p) {
$id = (int) $p;
if ($id > 0) {
$out[] = $id;
}
}
return array_values(array_unique($out));
}
private static function find_discount_post_id(string $code, int $seller_user_id = 0): int {
$args = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'numberposts' => 1,
'fields' => 'ids',
'no_found_rows' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required lookup by discount code.
'meta_query' => [
[
'key' => '_ToDownload_discount_code',
'value' => $code,
'compare' => '=',
],
],
];
$seller_user_id = (int) $seller_user_id;
if ($seller_user_id > 0) {
$args['author'] = $seller_user_id;
}
$posts = get_posts($args);
return !empty($posts) ? (int) $posts[0] : 0;
}
public static function on_order_complete(int $order_id): void {
$order = Orders::get_order($order_id);
if (!$order) {
return;
}
$notes = (string) ($order['notes'] ?? '');
if ($notes === '') {
return;
}
// Look for marker like: "ToDownload_discount_code: SAVE10"
if (!preg_match('/ToDownload_discount_code:\s*([A-Z0-9_-]+)/', $notes, $m)) {
return;
}
$code = strtoupper(trim((string) $m[1]));
if ($code === '') {
return;
}
$seller_user_id = (int) ($order['seller_user_id'] ?? 0);
$discount_post_id = self::find_discount_post_id($code, $seller_user_id);
if (!$discount_post_id) {
return;
}
$usage_count = (int) get_post_meta($discount_post_id, '_ToDownload_discount_usage_count', true);
update_post_meta($discount_post_id, '_ToDownload_discount_usage_count', $usage_count + 1);
}
}