Commit 4f1c9419 authored by Aleix Quintana Alsius's avatar Aleix Quintana Alsius
Browse files

first commit

parents
It will add two new fields to Order items:
- expiry, the date of the order items expiry.
- expiry_original_item, a reference to original order item.
The logic behind this module is that when a order is completed (moves from validation -> completed so it's named 'validate' transition) a field expiry is defined via (ExpiryEventSubscriber) with order completedTime plus period(a field with period as Php intervaldatetime[ 1M, 1D, 1Y,...]).
When expiry time is near a mail is sent to customer to start renewal process, (done via cron each day), by default it alerts 15D, 7D, 1D before expires. If customer wants to renew it can be done in renewal form (/user/{uid}/ordered-items/{order_item_id}/renew).
When renew is clicked a clone of order_item to be renewed is done, populating fields:
- and expiry_original_item: with reference to original item.
If cart is completed an event subscription ( ExpiryEventSubscriber ) subscribed to validate.post_transition will do some logics:
- basically will fill the
- set renewed order item expiry field: with original item expiry field plus period.
- set original order item order to state expired to disable notifications and
allow renewals.
## templates
The mail alerts can be themed.
@todo it remains as a todo the creation of autorenew process, reusing payment captured data.
@todo the servicerecurringpaymentsmanager currently only adds mail functionality and expiring, so renew and autorenew and preparecart or duplicatecart is not implemented or bad designed.
@todo Replace renew_url to /user/{uid}/ordered-items/{order_item_id}/renew in RecurringPaymentsManager.php
@todo add translate enabled templates for not implemented renew autorenew bad-autorenew
@todo By now it relies in one item per order as:
- if order item is renewed the whole original order state is marked as expired so if
there is another order item there it will not be checked for expiry. As the module algorithm only looks for orders in state completed. The workaround is to force one order item per cart.
It's not clear the correct behavior, maybe:
-if there are more than one order item check the dates and alter the treated order item expiry time in the long past and keep the order as completed.
-if there are more than one order item remove the treated item from the order and keep the order as completed.
## Dev notes
The production behavior is that via cron and once a day a check will be done looking for expiring soon or expired order items and appending to a working queue, after this check, a cron rerun will try to send the queued notifications to any customer, trying to send all mails it can for 10 seconds, the remaining ones will be sent in next cron runs.
The behavior can be tested via hidden url: admin/commerce/recurring-payments-test: Submitting this form will process the test, creating order with concrete item and changing expiring dates and emulating cron routine. Normally it needs to create a dummy order, then change the expiry, and then run the add to queue as daily cron does, after that a cron job will be ran once again to send all mails it can for 10 seconds, the remaining ones will be sent in next cron runs.
{
"name": "drupal/recurring_payments",
"type": "drupal-module",
"description": "Handles the payments that needs to be charged with periods.",
"keywords": ["Drupal"],
"license": "GPL-2.0+",
"homepage": "https://www.drupal.org/project/recurring_payments",
"minimum-stability": "dev",
"support": {
"issues": "https://www.drupal.org/project/issues/recurring_payments",
"source": "http://cgit.drupalcode.org/recurring_payments"
},
"require": { }
}
langcode: en
status: true
dependencies:
config:
- commerce_order.commerce_order_type.default
- field.storage.commerce_order.renew
id: commerce_order_item.default.expiry
field_name: renew
entity_type: commerce_order
bundle: default
label: 'Renew'
description: 'Renew this item automatically'
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings: { }
field_type: boolean
langcode: en
status: true
dependencies:
config:
- commerce_order.commerce_order_item_type.default
- field.storage.commerce_order_item.expiry
id: commerce_order_item.default.expiry
field_name: expiry
entity_type: commerce_order_item
bundle: default
label: 'Expire date'
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings: { }
field_type: timestamp
langcode: en
status: true
dependencies:
config:
- commerce_order.commerce_order_item_type.default
- field.storage.commerce_order_item.expiry_original_item
id: commerce_order_item.default.expiry_original_item
field_name: expiry_original_item
entity_type: commerce_order_item
bundle: default
label: 'Expire original item'
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
handler: 'default:commerce_order_item'
handler_settings:
target_bundles:
default: default
sort:
field: _none
auto_create: false
auto_create_bundle: ''
field_type: entity_reference
langcode: en
status: true
dependencies:
enforced:
module:
- recurring_payments
module:
- commerce_order
field_name: renew
id: commerce_order.renew
entity_type: commerce_order
type: boolean
settings: { }
module: core
locked: true
cardinality: 1
translatable: false
indexes: { }
persist_with_no_fields: false
custom_storage: false
langcode: en
status: true
dependencies:
enforced:
module:
- recurring_payments
module:
- commerce_order
field_name: expiry
id: commerce_order_item.expiry
entity_type: commerce_order_item
type: timestamp
settings: { }
module: core
locked: true
cardinality: 1
translatable: false
indexes: { }
persist_with_no_fields: false
custom_storage: false
langcode: en
status: true
dependencies:
enforced:
module:
- recurring_payments
module:
- commerce_order
field_name: expiry_original_item
id: commerce_order_item.expiry_original_item
entity_type: commerce_order_item
type: entity_reference
settings:
target_type: commerce_order_item
module: core
locked: true
cardinality: 1
translatable: false
indexes: { }
persist_with_no_fields: false
custom_storage: false
<?php
/**
* @file
* Contains recurring_payments.module.
*/
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function recurring_payments_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
// Main module help for the recurring_payments module.
case 'help.page.recurring_payments':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('Handles the payments that needs to be charged with periods.') . '</p>';
//recurring_payments_test_routine();
return $output;
default:
}
}
/**
* Implements hook_theme().
*/
function recurring_payments_theme($existing, $type, $theme, $path) {
return [
'recurring_payments_about_expire' => [
'variables' => [
'order_items' => NULL,
'user' => NULL,
'order_entity' => NULL,
'remaining_days' => NULL,
'renew_url' => NULL
]
],
'recurring_payments_renew' => [
'variables' => [
'order_items' => NULL,
'user' => NULL,
'order_entity' => NULL,
'period' => NULL,
]
],
'recurring_payments_autorenew' => [
'variables' => [
'order_items' => NULL,
'user' => NULL,
'order_entity' => NULL,
'period' => NULL,
]
],
'recurring_payments_bad_renew' => [
'variables' => [
'order_items' => NULL,
'user' => NULL,
'order_entity' => NULL,
'period' => NULL,
'renew_url' => NULL
]
],
'recurring_payments_expire' => [
'variables' => [
'order_items' => NULL,
'user' => NULL,
'order_entity' => NULL,
'period' => NULL,
'renew_url' => NULL
]
],
'recurring_payments' => [
'template' => 'recurring_payments',
'render element' => 'children',
],
];
}
/**
* Testing routine.
*/
function recurring_payments_test_routine(){
// @todo creating test suite.
//
//CreateOrder:
//kint(recurring_payments_test_create_order(20));
//Load item
// $order_item = entity_load_multiple('commerce_order_item',[163])[163];
// kint($order_item);
//Getting expiry
// kint($order_item->get('expiry')->first()->value);
//Adding expiry field
//$order_item->set('expiry',REQUEST_TIME);
//$order_item->save();
//Get attributes of purchased entity in commerce order item
//kint(entity_load_multiple('commerce_order_item', [10])[10]->getPurchasedEntity()->getAttributeValue('attribute_period')->getName());
// Set the order to expire today [10 bias to avoid matching also yesterday (-1)] (reminder action)
// $order_item->set('expiry', REQUEST_TIME + 20);
// $order_item->save();
// Set the order to expire in 15 days (reminder action)
//$order_item->set('expiry', REQUEST_TIME + ( 86400 * 15 ));
//$order_item->save();
//Set the order to expire yesterday. (expire or renew action)
// $order_item->set('expiry', REQUEST_TIME - 20 );
// $order_item->save();
//Set order item order_id
//$order_item->set('order_id', 28);
//$order_item->save();
//recurring_payments_crono();
}
/**
* Returns the interval to check given remaining days.
*
* It will be useful when base_time is known , the process is :
* Start as $base_time + $remaining_days.
* End as $base_time + $remaining_days + 1day
*
* @param int $base_time
* The time to offset the remaining days.
* @param int $remaining_days
* The remaining days to expire.
*
* @return array
* With the interval that will activate the expiration related actions.
*/
function _recurring_payments_action_interval($remaining_days, $base_time = REQUEST_TIME){
$action_interval = [];
$remaining=[
'15',
'7',
'1',
'0',
'-1'
];
$date = new DateTime();
$date->setTimestamp($base_time);
$date->modify($remaining_days . ' day');
$start = $date->getTimestamp();
$date->add(new DateInterval('P1D'));
$end = $date->getTimestamp();
$action_interval[(string)$remaining_days] = [$start, $end];
//TEST INTERVAL
$date->setTimestamp(REQUEST_TIME);
$date->setTimestamp(REQUEST_TIME);
$action_interval["test"][] = $date->getTimestamp();
$date->add(new DateInterval('P13M'));
$action_interval["test"][] = $date->getTimestamp();
return $action_interval;
}
/**
* Implements hook_cron().
*
* Queues orders that need to be checked for expiring or renew.
*/
function recurring_payments_cron() {
// Set interval for run cron. Default value is 1 day.
$recurring_payments_timestamp = Drupal::state()->get('recurring_payments.day_timestamp') ? : 0;
if ((REQUEST_TIME - $recurring_payments_timestamp) < 86400) { //to test < 10) {
// Must run once a day.
return;
}
$queue = \Drupal::queue('manual_recurring_payments');
$remaining=[
'+15' => [],
'+7' => [],
'+1' => [],
'+0' => [],
'-1' => [],
//TEST:
//'+364' => []
];
foreach($remaining as $remaining_days => $ids){
$query = \Drupal::entityQuery('commerce_order');
$interval = _recurring_payments_action_interval($remaining_days);
$query
->condition('order_items.entity.expiry',$interval[$remaining_days], "BETWEEN")
->condition('state', 'completed');
$ids = $query->execute();
if ($ids){
$order_storage = \Drupal::entityManager()->getStorage('commerce_order');
$orders = $order_storage->loadMultiple($ids);
foreach ($orders as $id => $order){
$item = [
"order" => $order,
"id" => $id,
"remaining_days" => $remaining_days,
"act_interval" => $interval[$remaining_days]
];
$queue->createItem($item );
}
}
}
Drupal::state()->set('recurring_payments.day_timestamp', REQUEST_TIME);
}
/* $ids = \Drupal::entityManager()->getStorage('aggregator_feed')->getFeedIdsToRefresh();
foreach (Feed::loadMultiple($ids) as $feed) {
if ($queue->createItem($feed)) {
// Add timestamp to avoid queueing item more than once.
$feed->setQueuedTime(REQUEST_TIME);
$feed->save();
}
}
// Delete queued timestamp after 6 hours assuming the update has failed.
$ids = \Drupal::entityQuery('aggregator_feed')
->condition('queued', REQUEST_TIME - (3600 * 6), '<')
->execute();
if ($ids) {
$feeds = Feed::loadMultiple($ids);
foreach ($feeds as $feed) {
$feed->setQueuedTime(0);
$feed->save();
}
}
}*/
/**
* Sets order_item order_id ref property to order when a order_item is updated
*
* We need this as when we check in the future if a order_item is expiring we
* need to deal with duplicated references.Also removes reference from other
* orders, as we don't want that order delete deletes reused order items.
*
* @todo We nee to deal with log of orders, as this operation breaks old orders
*/
function recurring_payments_commerce_order_presave(Drupal\commerce_order\Entity\OrderInterface $order) {
$items = $order->getItems();
foreach ($items as $item){
$query = \Drupal::entityQuery('commerce_order');
$query
->condition('order_items',$item->id())
->condition('state', 'completed');
$ids = $query->execute();
if ($ids){
$order_storage = \Drupal::entityManager()->getStorage('commerce_order');
$orders = $order_storage->loadMultiple($ids);
foreach ($orders as $id => $existing_order){
$existing_order_items = $existing_order->getItems();
$new_order_items = array_udiff($existing_order_items, [$item], function ($i, $ii){return $i->id()-$ii->id();});
$existing_order->setItems($new_order_items);
$existing_order->save();
}
}
// Set the order_id in items to fix order items list of order
$item->set('order_id',$order->id());
$item->save();
}
}
/**
* Removes order item from order if it is used by other order to avoid
* deleting orders in Order::postDelete.
*/
function recurring_payments_commerce_order_predelete(Drupal\Core\Entity\EntityInterface $entity){
$items = $entity->getItems();
foreach ($items as $item){
$query = \Drupal::entityQuery('commerce_order');
$query
->condition('order_items',$item->id());
$ids = $query->execute();
//exclude the current entity
$ids_clean = array_diff($ids, [$entity->id()]);
if (!empty($ids_clean)){
$existing_order_items = $entity->getItems();
$new_order_items = array_udiff($existing_order_items, [$item], function ($i, $ii){return $i->id()-$ii->id();});
$entity->setItems($new_order_items);
// Set the order_id in items to fix order items list of order
// to last order id in query results.
$item->set('order_id',array_pop($ids_clean));
$item->save();
}
}
}
/**
* Clones order item and pass to previous order before removal of this item.
*
*/
function recurring_payments_commerce_order_item_predelete(Drupal\Core\Entity\EntityInterface $order_item){
return;
// look for other order that use this item.
$query = \Drupal::entityQuery('commerce_order');
$query
->condition('order_items',$order_item->id());
$ids = $query->execute();
//exclude the current entity
$ids_clean = [$order_item->getOrderId()];
if (!empty($ids_clean)){
// Create a copy
$order_item_copy = $order_item->createDuplicate();
$order_item_copy->save();
$orders = entity_load_multiple('commerce_order', $ids_clean);
// if others orders using this add the new clone of order_item
// to let $entity order item be removed.
foreach($orders as $id => $order){
$order->addItem($order_item_copy);
$order->save;
}
}
}
/**
* Implements hook_mail().
*
* Captures the outgoing mail and sets appropriate message body and headers.
*/
function recurring_payments_mail($key, &$message, $params) {
if (isset($params['headers'])) {
$message['headers'] = array_merge($message['headers'], $params['headers']);
}
$message['from'] = $params['from'];
$message['subject'] = $params['subject'];
$message['body'][] = $params['body'];
}
/**
* DEPRECATED Gets the interval to check given order period and remaining days.
*
* It will be useful when completed_time is UNKNOWN , the process is :
* Start as REQUEST_TIME - $period + $remaining_days.
* End as REQUEST_TIME- $period + $remaining_days + 1day
*
* @param int $remaining_days
* The remaining days to expire.
*
* @return array
* Periods intervals that will trig the expiration related actions from now.
*/
function _recurring_payments_action_interval_without_expire($remaining_days){
$action_interval = [];
$int_period=[
'monthly' => 1,
'quarterly' => 3,
'annual' => 12,
];
$date = new DateTime();
foreach($int_period as $period => $q_months){
$date->setTimestamp(REQUEST_TIME);
$date->sub(new DateInterval('P' . $q_months . 'M'));
$date->modify('+' . $remaining_days . ' day');
$action_interval[$period][] = $date->getTimestamp();
$date->add(new DateInterval('P1D'));
$action_interval[$period][] = $date->getTimestamp();
}
$date->setTimestamp(REQUEST_TIME);
$date->sub(new DateInterval('P10M'));
$action_interval["test"][] = $date->getTimestamp();
$date->setTimestamp(REQUEST_TIME);
$action_interval["test"][] = $date->getTimestamp();
return $action_interval;
}
name: Recurring payments
type: module
description: Handles the payments that needs to be charged with periods.
core: 8.x
package: Commerce
entity.commerce_order_item.renew:
route_name: entity.commerce_order_item.renew
base_route: entity.commerce_order_item.renew
title: 'Renew'
<?php
/**
* @file
* Contains recurring_payments.module.
*/
use Drupal\Core\Entity\EntityInterface;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function recurring_payments_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
// Main module help for the recurring_payments module.
case 'help.page.recurring_payments':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('Handles the payments that needs to be charged with periods.') . '</p>';
// recurring_payments_test_routine();
return $output;
default:
}
}
/**
* Implements hook_theme().
*/
function recurring_payments_theme($existing, $type, $theme, $path) {
return [