id() ) { return; } as_enqueue_async_action( self::JOB_HOOK, array( PurchaseReceiptEmail::ID, (int) $order->id() ), self::JOB_GROUP ); } /** * Queue delivery for a licence minted after the order receipt. * * During checkout, the queued receipt carries the licence key. * * @since 1.0.0 * * @param License $license The freshly minted license. * @param Order $order The order that earned it. * @return void */ public function onLicenseGenerated( License $license, Order $order ): void { if ( null === $license->id() || null === $order->id() ) { return; } $receiptSent = get_metadata( self::ORDER_META_TYPE, (int) $order->id(), self::RECEIPT_SENT_META, true ); if ( '' === $receiptSent || false === $receiptSent ) { // The receipt has not gone out yet; it will include this key. return; } as_enqueue_async_action( self::JOB_HOOK, array( LicenseDeliveryEmail::ID, (int) $license->id() ), self::JOB_GROUP ); } /** * Reload data and send a queued email without failing the queue runner. * * @since 1.0.0 * * @param string $emailId The email id to dispatch. * @param int $objectId The order id (receipt) or license id (delivery). * @return void */ public function onSendEmailJob( string $emailId, int $objectId ): void { try { $email = $this->emails->get( $emailId ); if ( null === $email ) { $this->logger->log( 'error', sprintf( 'Cannot dispatch unknown email "%s".', $emailId ), array( 'object_id' => $objectId ) ); return; } if ( PurchaseReceiptEmail::ID === $emailId ) { $this->sendReceipt( $email, $objectId ); return; } if ( LicenseDeliveryEmail::ID === $emailId ) { $this->sendLicenseDelivery( $email, $objectId ); return; } // A third party enqueued an email the listener does not know how to // build context for. Log rather than guess at a context. $this->logger->log( 'error', sprintf( 'No context builder for email "%s".', $emailId ), array( 'object_id' => $objectId ) ); } catch ( Throwable $failure ) { $this->logger->log( 'error', sprintf( 'Email job for "%s" (object %d) failed: %s', $emailId, $objectId, $failure->getMessage() ), array( 'object_id' => $objectId ) ); } } /** * Send an order receipt once, for a delivering order. * * Mark a receipt only after a successful send. * * @since 1.0.0 * * @param Email $email The receipt email. * @param int $orderId The order to receipt. * @return void */ private function sendReceipt( Email $email, int $orderId ): void { $order = $this->orders->get( $orderId ); if ( null === $order ) { $this->logger->log( 'error', sprintf( 'Receipt job skipped: order %d no longer exists.', $orderId ), array(), 'order', $orderId ); return; } if ( ! $this->orderDelivers( $order ) ) { $this->logger->log( 'error', sprintf( 'Receipt job skipped: order %d is %s, not a delivering status.', $orderId, $order->status()->value ), array(), 'order', $orderId ); return; } // Idempotency: a receipt already sent for this order is a no-op, so a // replayed or double-enqueued job never sends a second receipt. $alreadySent = get_metadata( self::ORDER_META_TYPE, $orderId, self::RECEIPT_SENT_META, true ); if ( '' !== $alreadySent && false !== $alreadySent ) { return; } $sent = $this->mailer->send( $email, $this->receiptContext( $order ) ); if ( $sent ) { // Stamp only after a real send so a failed attempt is retried, not // swallowed, while a success can never be re-sent. update_metadata( self::ORDER_META_TYPE, $orderId, self::RECEIPT_SENT_META, gmdate( 'Y-m-d H:i:s' ) ); } } /** * Send a standalone license delivery for one license. Skips (logged) when the * license or its order has vanished. * * @since 1.0.0 * * @param Email $email The license delivery email. * @param int $licenseId The license to deliver. * @return void */ private function sendLicenseDelivery( Email $email, int $licenseId ): void { // Idempotency mirror of the receipt marker: only the first job for a // license sends; replays and double-enqueues skip silently. $alreadySent = get_metadata( self::LICENSE_META_TYPE, $licenseId, self::DELIVERY_SENT_META, true ); if ( '' !== $alreadySent && false !== $alreadySent ) { return; } $license = $this->licenses->get( $licenseId ); if ( null === $license ) { $this->logger->log( 'error', sprintf( 'License delivery job skipped: license %d no longer exists.', $licenseId ), array(), 'license', $licenseId ); return; } $order = $this->orders->get( $license->orderId() ); if ( null === $order ) { $this->logger->log( 'error', sprintf( 'License delivery job skipped: order %d for license %d no longer exists.', $license->orderId(), $licenseId ), array(), 'license', $licenseId ); return; } if ( $this->mailer->send( $email, $this->licenseDeliveryContext( $license, $order ) ) ) { update_metadata( self::LICENSE_META_TYPE, $licenseId, self::DELIVERY_SENT_META, gmdate( 'Y-m-d H:i:s' ) ); } } /** * Build receipt data from an order, licences and download links. * * @since 1.0.0 * * @param Order $order The completed order. * @return array The receipt view context. */ private function receiptContext( Order $order ): array { $currency = $order->currency(); $items = array(); foreach ( $order->items() as $item ) { $items[] = array( 'name' => $item->productName(), 'quantity' => $item->quantity(), 'subtotal' => $this->money->format( $item->subtotal(), $currency ), 'total' => $this->money->format( $item->total(), $currency ), ); } $licenses = array(); foreach ( $this->licenses->listByOrder( (int) $order->id() ) as $license ) { $product = $this->products->get( $license->productId() ); $licenses[] = array( 'product_name' => null !== $product ? $product->name() : '', 'license_key' => $license->licenseKey(), ); } // The discount line only exists when a discount actually reduced the order. $discount = null; if ( $order->discountTotal() > 0 ) { $discount = array( 'code' => (string) $order->discountCode(), 'amount' => $this->money->format( $order->discountTotal(), $currency ), ); } return array( 'email_id' => PurchaseReceiptEmail::ID, 'store_name' => (string) get_bloginfo( 'name' ), 'recipient' => $order->email(), 'order' => array( 'id' => (int) $order->id(), 'reference' => (string) $order->id(), 'uuid' => $order->uuid(), 'date' => $this->formatDate( $order->createdAt() ), ), 'items' => $items, 'subtotal' => $this->money->format( $order->subtotal(), $currency ), 'discount' => $discount, 'total' => $this->money->format( $order->total(), $currency ), 'licenses' => $licenses, 'downloads' => $this->downloads->entriesForOrder( $order, self::EMAIL_DOWNLOAD_TTL ), ); } /** * Build licence delivery data from a licence and order. * * @since 1.0.0 * * @param License $license The license to deliver. * @param Order $order The order the license belongs to. * @return array The license-delivery view context. */ private function licenseDeliveryContext( License $license, Order $order ): array { $product = $this->products->get( $license->productId() ); $expires = $license->expiresAt(); return array( 'email_id' => LicenseDeliveryEmail::ID, 'store_name' => (string) get_bloginfo( 'name' ), 'recipient' => $order->email(), 'product_name' => null !== $product ? $product->name() : '', 'license_key' => $license->licenseKey(), 'expires_at' => null !== $expires ? $this->formatDate( $expires->format( 'Y-m-d H:i:s' ) ) : '', 'license_id' => (int) $license->id(), 'order' => array( 'id' => (int) $order->id(), 'reference' => (string) $order->id(), 'uuid' => $order->uuid(), ), 'downloads' => $this->downloads->entriesForOrder( $order, self::EMAIL_DOWNLOAD_TTL ), ); } /** * Check whether an order status sends a receipt. * * @since 1.0.0 * * @param Order $order The order. * @return bool True when a receipt should be sent. */ private function orderDelivers( Order $order ): bool { return in_array( $order->status(), array( OrderStatus::Completed, OrderStatus::PartiallyRefunded ), true ); } /** * Format a UTC timestamp with the site's date settings. * * @since 1.0.0 * * @param string|null $utc A 'Y-m-d H:i:s' UTC timestamp, or null. * @return string The localized date, or '' when the input was empty/unparseable. */ private function formatDate( ?string $utc ): string { if ( null === $utc || '' === $utc ) { return ''; } $timestamp = strtotime( $utc . ' UTC' ); if ( false === $timestamp ) { return ''; } $format = (string) get_option( 'date_format' ); if ( '' === $format ) { $format = 'Y-m-d'; } return (string) wp_date( $format, $timestamp ); } }