$spec `type` (`percent` or `fixed_cart`), `amount`, `minimum` (0 for none), * `hours` (validity), `prefix` (two to four letters, default `RK`). * @param array $context Ids for the event, e.g. `cart_id`. * * @return array{id: int, code: string, expires: int}|null Null when WooCommerce is missing or the spec is unusable. */ public static function issue( string $module, string $flow, string $phone, array $spec, array $context = [] ): ?array { if ( ! class_exists( WC_Coupon::class ) ) { return null; } $type = 'fixed_cart' === ( $spec['type'] ?? '' ) ? 'fixed_cart' : 'percent'; $amount = (float) ( $spec['amount'] ?? 0 ); $minimum = max( 0.0, (float) ( $spec['minimum'] ?? 0 ) ); $hours = max( 1, (int) ( $spec['hours'] ?? 48 ) ); $prefix = strtoupper( (string) preg_replace( '/[^A-Za-z]/', '', (string) ( $spec['prefix'] ?? 'RK' ) ) ); $prefix = '' === $prefix ? 'RK' : substr( $prefix, 0, 4 ); if ( $amount <= 0 || ( 'percent' === $type && $amount > 100 ) ) { return null; } $expires = time() + ( $hours * HOUR_IN_SECONDS ); for ( $try = 0; $try < 3; $try++ ) { $code = $prefix . '-' . self::randomPart(); if ( wc_get_coupon_id_by_code( $code ) > 0 ) { continue; } $coupon = new WC_Coupon(); $coupon->set_code( $code ); $coupon->set_discount_type( $type ); $coupon->set_amount( $amount ); $coupon->set_minimum_amount( $minimum > 0 ? $minimum : '' ); $coupon->set_usage_limit( 1 ); $coupon->set_usage_limit_per_user( 1 ); $coupon->set_individual_use( true ); $coupon->set_date_expires( $expires ); /* translators: %s: module slug */ $coupon->set_description( sprintf( __( 'کوپن یکتای ماژول %s (راهکار)', 'rahkar-toolkit' ), $module ) ); $coupon->update_meta_data( self::META_ISSUER, sanitize_key( $module ) ); $id = $coupon->save(); if ( $id <= 0 ) { break; } self::report( $coupon, $module, $flow, $phone, $context ); return [ 'id' => $id, 'code' => $coupon->get_code(), 'expires' => $expires, ]; } Logger::error( 'coupons', 'Could not create a coupon.', [ 'module' => $module ] ); return null; } /** * Delete unused coupons a module made that expired long ago. * * Used coupons stay: an order points at them. Deleted for good, which the * connector reports as `coupon.deleted`. * * @param int $limit Coupons per call. * * @return int Coupons deleted. */ public static function purge( int $limit = 50 ): int { // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Runs from a periodic job, a few dozen rows at most. $ids = get_posts( [ 'post_type' => 'shop_coupon', 'post_status' => 'any', 'posts_per_page' => $limit, 'fields' => 'ids', 'no_found_rows' => true, 'meta_query' => [ 'relation' => 'AND', [ 'key' => self::META_ISSUER, 'compare' => 'EXISTS', ], [ 'key' => 'date_expires', 'value' => time() - ( self::KEEP_DAYS * DAY_IN_SECONDS ), 'compare' => '<', 'type' => 'NUMERIC', ], [ 'key' => 'usage_count', 'value' => 0, 'compare' => '=', 'type' => 'NUMERIC', ], ], ] ); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query $deleted = 0; foreach ( $ids as $id ) { if ( wp_delete_post( (int) $id, true ) ) { ++$deleted; } } return $deleted; } /** * Six characters from the readable alphabet. */ private static function randomPart(): string { $part = ''; for ( $i = 0; $i < 6; $i++ ) { $part .= self::ALPHABET[ random_int( 0, strlen( self::ALPHABET ) - 1 ) ]; } return $part; } /** * Report `coupon.issued` (EVENTS-v1.md §9). * * @param WC_Coupon $coupon The new coupon. * @param string $module Issuing module. * @param string $flow Flow slug, or empty. * @param string $phone Recipient. * @param array $context Ids. */ private static function report( WC_Coupon $coupon, string $module, string $flow, string $phone, array $context ): void { if ( ! Events::listening() ) { return; } $number = PhoneNumber::parse( $phone ); $minimum = (float) $coupon->get_minimum_amount(); $expires = $coupon->get_date_expires(); $data = [ 'coupon_id' => (string) $coupon->get_id(), 'code' => strtolower( $coupon->get_code() ), 'module' => sanitize_key( $module ), 'flow' => '' === $flow ? null : sanitize_key( $flow ), 'discount_type' => sanitize_key( $coupon->get_discount_type() ), 'amount' => self::money( (string) $coupon->get_amount() ), 'currency' => strtoupper( get_woocommerce_currency() ), 'minimum_amount' => $minimum > 0 ? self::money( (string) $minimum ) : null, 'expires_at' => null === $expires ? null : gmdate( 'Y-m-d\TH:i:s\Z', $expires->getTimestamp() ), 'phone' => null === $number ? null : $number->canonical(), ]; $context = Message::context( $context ); if ( [] !== $context ) { $data['context'] = $context; } Events::emit( 'coupon.issued', $data ); } /** * A decimal string the contract accepts. * * @param string $amount Amount. */ private static function money( string $amount ): string { $value = str_contains( $amount, '.' ) ? rtrim( rtrim( $amount, '0' ), '.' ) : $amount; return '' === $value ? '0' : $value; } }