diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2046,6 +2046,7 @@ 'PhabricatorObjectSelectorDialog' => 'view/control/PhabricatorObjectSelectorDialog.php', 'PhabricatorObjectUsesCredentialsEdgeType' => 'applications/transactions/edges/PhabricatorObjectUsesCredentialsEdgeType.php', 'PhabricatorOffsetPagedQuery' => 'infrastructure/query/PhabricatorOffsetPagedQuery.php', + 'PhabricatorOneTimeTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php', 'PhabricatorOwnerPathQuery' => 'applications/owners/query/PhabricatorOwnerPathQuery.php', 'PhabricatorOwnersApplication' => 'applications/owners/application/PhabricatorOwnersApplication.php', 'PhabricatorOwnersConfigOptions' => 'applications/owners/config/PhabricatorOwnersConfigOptions.php', @@ -2457,6 +2458,7 @@ 'PhabricatorSubscribableInterface' => 'applications/subscriptions/interface/PhabricatorSubscribableInterface.php', 'PhabricatorSubscribedToObjectEdgeType' => 'applications/transactions/edges/PhabricatorSubscribedToObjectEdgeType.php', 'PhabricatorSubscribersQuery' => 'applications/subscriptions/query/PhabricatorSubscribersQuery.php', + 'PhabricatorSubscriptionTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php', 'PhabricatorSubscriptionsApplication' => 'applications/subscriptions/application/PhabricatorSubscriptionsApplication.php', 'PhabricatorSubscriptionsEditController' => 'applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php', 'PhabricatorSubscriptionsEditor' => 'applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php', @@ -2520,6 +2522,8 @@ 'PhabricatorTransformedFile' => 'applications/files/storage/PhabricatorTransformedFile.php', 'PhabricatorTranslation' => 'infrastructure/internationalization/translation/PhabricatorTranslation.php', 'PhabricatorTranslationsConfigOptions' => 'applications/config/option/PhabricatorTranslationsConfigOptions.php', + 'PhabricatorTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php', + 'PhabricatorTriggerClockTestCase' => 'infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php', 'PhabricatorTrivialTestCase' => 'infrastructure/testing/__tests__/PhabricatorTrivialTestCase.php', 'PhabricatorTwitchAuthProvider' => 'applications/auth/provider/PhabricatorTwitchAuthProvider.php', 'PhabricatorTwitterAuthProvider' => 'applications/auth/provider/PhabricatorTwitterAuthProvider.php', @@ -5246,6 +5250,7 @@ 'PhabricatorObjectRemarkupRule' => 'PhutilRemarkupRule', 'PhabricatorObjectUsesCredentialsEdgeType' => 'PhabricatorEdgeType', 'PhabricatorOffsetPagedQuery' => 'PhabricatorQuery', + 'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock', 'PhabricatorOwnersApplication' => 'PhabricatorApplication', 'PhabricatorOwnersConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorOwnersController' => 'PhabricatorController', @@ -5711,6 +5716,7 @@ 'PhabricatorStorageSetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorSubscribedToObjectEdgeType' => 'PhabricatorEdgeType', 'PhabricatorSubscribersQuery' => 'PhabricatorQuery', + 'PhabricatorSubscriptionTriggerClock' => 'PhabricatorTriggerClock', 'PhabricatorSubscriptionsApplication' => 'PhabricatorApplication', 'PhabricatorSubscriptionsEditController' => 'PhabricatorController', 'PhabricatorSubscriptionsEditor' => 'PhabricatorEditor', @@ -5772,6 +5778,8 @@ 'PhabricatorTransactionsApplication' => 'PhabricatorApplication', 'PhabricatorTransformedFile' => 'PhabricatorFileDAO', 'PhabricatorTranslationsConfigOptions' => 'PhabricatorApplicationConfigOptions', + 'PhabricatorTriggerClock' => 'Phobject', + 'PhabricatorTriggerClockTestCase' => 'PhabricatorTestCase', 'PhabricatorTrivialTestCase' => 'PhabricatorTestCase', 'PhabricatorTwitchAuthProvider' => 'PhabricatorOAuth2AuthProvider', 'PhabricatorTwitterAuthProvider' => 'PhabricatorOAuth1AuthProvider', diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php @@ -0,0 +1,25 @@ + 'int', + )); + } + + public function getNextEventEpoch($last_epoch, $is_reschedule) { + if ($last_epoch) { + return null; + } + + return $this->getProperty('epoch'); + } + +} diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php @@ -0,0 +1,81 @@ + 'int', + )); + } + + public function getNextEventEpoch($last_epoch, $is_reschedule) { + $start_epoch = $this->getProperty('start'); + if (!$last_epoch) { + $last_epoch = $start_epoch; + } + + // Constructing DateTime objects like this implies UTC, so we don't need + // to set that explicitly. + $start = new DateTime('@'.$start_epoch); + $last = new DateTime('@'.$last_epoch); + + $year = (int)$last->format('Y'); + $month = (int)$last->format('n'); + + // Note that we're getting the day of the month from the start date, not + // from the last event date. This lets us schedule on March 31 after moving + // the date back to Feb 28. + $day = (int)$start->format('j'); + + // We trigger at the same time of day as the original event. Generally, + // this means that you should get invoiced at a reasonable local time in + // most cases, unless you subscribed at 1AM or something. + $hms = $start->format('G:i:s'); + + // Increment the month by 1. + $month = $month + 1; + + // If we ran off the end of the calendar, set the month back to January + // and increment the year by 1. + if ($month > 12) { + $month = 1; + $year = $year + 1; + } + + // Now, move the day backward until it falls in the correct month. If we + // pass an invalid date like "2014-2-31", it will internally be parsed + // as though we had passed "2014-3-3". + while (true) { + $next = new DateTime("{$year}-{$month}-{$day} {$hms} UTC"); + if ($next->format('n') == $month) { + // The month didn't get corrected forward, so we're all set. + break; + } else { + // The month did get corrected forward, so back off a day. + $day--; + } + } + + return (int)$next->format('U'); + } + +} diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php @@ -0,0 +1,74 @@ +validateProperties($properties); + $this->properties = $properties; + } + + public function getProperties() { + return $this->properties; + } + + public function getProperty($key, $default = null) { + return idx($this->properties, $key, $default); + } + + + /** + * Validate clock configuration. + * + * @param map Map of clock properties. + * @return void + */ + abstract public function validateProperties(array $properties); + + + /** + * Get the next occurrence of this event. + * + * This method takes two parameters: the last time this event occurred (or + * null if it has never triggered before) and a flag distinguishing between + * a normal reschedule (after a successful trigger) or an update because of + * a trigger change. + * + * If this event does not occur again, return `null` to stop it from being + * rescheduled. For example, a meeting reminder may be sent only once before + * the meeting. + * + * If this event does occur again, return the epoch timestamp of the next + * occurrence. + * + * When performing routine reschedules, the event must move forward in time: + * any timestamp you return must be later than the last event. For instance, + * if this event triggers an invoice, the next invoice date must be after + * the previous invoice date. This prevents an event from looping more than + * once per second. + * + * In contrast, after an update (not a routine reschedule), the next event + * may be scheduled at any time. For example, if a meeting is moved from next + * week to 3 minutes from now, the clock may reschedule the notification to + * occur 12 minutes ago. This will cause it to execute immediately. + * + * @param int|null Last time the event occurred, or null if it has never + * triggered before. + * @param bool True if this is a reschedule after a successful trigger. + * @return int|null Next event, or null to decline to reschedule. + */ + abstract public function getNextEventEpoch($last_epoch, $is_reschedule); + +} diff --git a/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php b/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php @@ -0,0 +1,85 @@ + $now, + )); + + $this->assertEqual( + $now, + $clock->getNextEventEpoch(null, false), + pht('Should trigger at specified epoch.')); + + $this->assertEqual( + null, + $clock->getNextEventEpoch(1, false), + pht('Should trigger only once.')); + } + + public function testSubscriptionTriggerClock() { + $start = strtotime('2014-01-31 2:34:56 UTC'); + + $clock = new PhabricatorSubscriptionTriggerClock( + array( + 'start' => $start, + )); + + $expect_list = array( + // This should be moved to the 28th of February. + '2014-02-28 2:34:56', + + // In March, which has 31 days, it should move back to the 31st. + '2014-03-31 2:34:56', + + // On months with only 30 days, it should occur on the 30th. + '2014-04-30 2:34:56', + '2014-05-31 2:34:56', + '2014-06-30 2:34:56', + '2014-07-31 2:34:56', + '2014-08-31 2:34:56', + '2014-09-30 2:34:56', + '2014-10-31 2:34:56', + '2014-11-30 2:34:56', + '2014-12-31 2:34:56', + + // After billing on Dec 31 2014, it should wrap around to Jan 31 2015. + '2015-01-31 2:34:56', + '2015-02-28 2:34:56', + '2015-03-31 2:34:56', + '2015-04-30 2:34:56', + '2015-05-31 2:34:56', + '2015-06-30 2:34:56', + '2015-07-31 2:34:56', + '2015-08-31 2:34:56', + '2015-09-30 2:34:56', + '2015-10-31 2:34:56', + '2015-11-30 2:34:56', + '2015-12-31 2:34:56', + '2016-01-31 2:34:56', + + // Finally, this should bill on leap day in 2016. + '2016-02-29 2:34:56', + '2016-03-31 2:34:56', + ); + + $last_epoch = null; + foreach ($expect_list as $cycle => $expect) { + $next_epoch = $clock->getNextEventEpoch( + $last_epoch, + ($last_epoch !== null)); + + $this->assertEqual( + $expect, + id(new DateTime('@'.$next_epoch))->format('Y-m-d g:i:s'), + pht('Billing cycle %s.', $cycle)); + + $last_epoch = $next_epoch; + } + } + +}