1
0

AnalyticsRevenueTest.php 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953
  1. <?php
  2. /**
  3. * Payment alerts into the revenue ledger, and back out through the revenue reports.
  4. * Orders and transactions are seeded in the payment module's tables with the order number
  5. * `test-rev-*`, dated September 2001 like the other analytics tests, and deleted afterwards.
  6. */
  7. class AnalyticsRevenueTest extends BxDolIntegrationTestCase
  8. {
  9. // 2001-09-09 01:46:40 UTC
  10. const NOW = 1000000000;
  11. const UA_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15';
  12. const CLIENT = 987654401;
  13. const SELLER = 987654402;
  14. protected $_oModule;
  15. protected $_aOptions = [];
  16. protected $_aPending = [];
  17. protected $_iOrder = 0;
  18. protected $_iModuleId = 0;
  19. protected $_aMatrix = [];
  20. protected function setUp(): void
  21. {
  22. parent::setUp();
  23. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_analytics'))
  24. $this->markTestSkipped('bx_analytics is not installed');
  25. $oPayments = BxDolPayments::getInstance();
  26. if (!$oPayments->isActive() || $oPayments->getActive() != 'bx_payment')
  27. $this->markTestSkipped('bx_payment is not the active payment module');
  28. // amounts below are in USD at rate 1
  29. if ($oPayments->getCurrencyCode() != 'USD')
  30. $this->markTestSkipped('the default currency of bx_payment is not USD');
  31. $this->_oModule = BxDolModule::getInstance('bx_analytics');
  32. $this->_oModule->getRevenue(); // loads the classes
  33. $this->_oModule->getFilter([]);
  34. // a module that sells: payment's own commission invoices are always there
  35. $aModule = BxDolModuleQuery::getInstance()->getModuleByName('bx_payment');
  36. $this->_iModuleId = (int)$aModule['id'];
  37. $this->_cleanup();
  38. foreach (['bx_analytics_track_actions' => 'on', 'bx_analytics_tracking' => 'on', 'bx_analytics_platforms' => 'core-web,next-web,rn-ios,rn-android,api,core', 'bx_analytics_respect_dnt' => 'on', 'bx_analytics_link_members' => 'on', 'bx_analytics_identity' => 'cookieless', 'bx_analytics_exclude_ips' => '', 'bx_analytics_exclude_levels' => '', 'bx_analytics_exclude_paths' => ''] as $sName => $sValue)
  39. $this->_setOption($sName, $sValue);
  40. }
  41. protected function tearDown(): void
  42. {
  43. if ($this->_oModule) {
  44. $this->_cleanup();
  45. foreach ($this->_aOptions as $sName => $sValue)
  46. setParam($sName, $sValue);
  47. $this->_restoreMatrix();
  48. }
  49. $GLOBALS['glForceCurrentProfileId'] = 0;
  50. parent::tearDown();
  51. }
  52. public function testSaleInTheBuyersRequest()
  53. {
  54. // the buyer is browsing in the iOS app
  55. $aServer = $this->_server(['HTTP_X_NEO_PLATFORM' => 'ios']);
  56. $oCollector = new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), self::NOW - 60);
  57. $this->assertSame(1, $oCollector->collect(['platform' => 'rn-ios', 'app' => 'com.neo.so', 'hits' => [['type' => 'pageview', 'url' => '/page/checkout']]], ['profile_id' => self::CLIENT]));
  58. $iSession = (int)BxDolDb::getInstance()->getOne("SELECT `id` FROM `bx_analytics_sessions` WHERE `profile_id` = :p AND `started` < :to", ['p' => self::CLIENT, 'to' => self::NOW + 86400]);
  59. $this->assertGreaterThan(0, $iSession);
  60. $aEur = $this->_order('single', 'stripe_v3', 'EUR', 50, ['cur_def' => 'USD', 'cur_rate' => 2]);
  61. $this->_transaction($aEur, 50);
  62. $this->_buyer($aServer)->onPaymentAlert($this->_alert('register_payment', ['pending' => $aEur]));
  63. $aRow = $this->_ledger($aEur)[0];
  64. $this->assertSame(['sale', 'rn-ios', $iSession, 1, 'EUR', '25.0000'], [$aRow['kind'], $aRow['platform'], (int)$aRow['session_id'], (int)$aRow['cash'], $aRow['currency'], $aRow['amount_default']], 'converted with the order\'s own rate');
  65. $this->assertSame('bx_payment', $aRow['module']);
  66. $aUsd = $this->_order('single', 'stripe_v3', 'USD', 10);
  67. $this->_transaction($aUsd, 10);
  68. $this->_buyer($aServer)->onPaymentAlert($this->_alert('register_payment', ['pending' => $aUsd]));
  69. $this->assertSame('10.0000', $this->_ledger($aUsd)[0]['amount_default'], 'the default currency has rate 1');
  70. $aGbp = $this->_order('single', 'stripe_v3', 'GBP', 7);
  71. $this->_transaction($aGbp, 7);
  72. $this->_buyer($aServer)->onPaymentAlert($this->_alert('register_payment', ['pending' => $aGbp]));
  73. $this->assertNull($this->_ledger($aGbp)[0]['amount_default'], 'no rate known');
  74. $aRevenue = $this->_oModule->getReports()->getRevenue($this->_filter(), []);
  75. $aTiles = array_column($aRevenue['tiles'], 'value', 'key');
  76. $this->assertEquals(35.0, $aTiles['net']);
  77. $this->assertSame(3, $aTiles['orders']);
  78. $this->assertSame(1, $aTiles['customers']);
  79. $this->assertCount(2, $aRevenue['notes'], 'a payment without an exchange rate, and how amounts are counted');
  80. // the sale is an action of the visit, so goals and funnels can end in it
  81. $aEvents = BxDolDb::getInstance()->getAll("SELECT `session_id`, `name`, `value`, `platform` FROM `bx_analytics_events` WHERE `type` = 'una' AND `name` = 'purchase' AND `ts` < :to ORDER BY `id`", ['to' => self::NOW + 86400]);
  82. $this->assertCount(3, $aEvents);
  83. $this->assertEquals(['session_id' => $iSession, 'name' => 'purchase', 'value' => 25, 'platform' => 'rn-ios'], $aEvents[0]);
  84. $sProps = BxDolDb::getInstance()->getOne("SELECT `props` FROM `bx_analytics_events` WHERE `type` = 'una' AND `name` = 'purchase' AND `ts` < :to ORDER BY `id` LIMIT 1", ['to' => self::NOW + 86400]);
  85. $this->assertSame(['provider' => 'stripe_v3'], json_decode($sProps, true), 'no amounts or order numbers');
  86. }
  87. public function testWebhookThenFinalize()
  88. {
  89. $aPending = $this->_order('single', 'stripe_v3', 'USD', 20);
  90. $this->_transaction($aPending, 20);
  91. // the provider's webhook: nobody is signed in
  92. $oWebhook = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest(['REQUEST_METHOD' => 'POST', 'REMOTE_ADDR' => '54.187.174.169', 'HTTP_USER_AGENT' => 'Stripe/1.0']), self::NOW, ['api' => false, 'api_key' => false, 'logged' => 0]);
  93. $oWebhook->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  94. $this->assertSame('core', $this->_ledger($aPending)[0]['platform'], 'a webhook POST is not the classic web UI');
  95. $aServer = $this->_server();
  96. (new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), self::NOW))->collect(['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/checkout']]], ['profile_id' => self::CLIENT, 'platform' => 'core-web']);
  97. $this->_buyer($aServer, 'core-web')->onPaymentAlert($this->_alert('finalize_checkout', ['pending' => $aPending], self::CLIENT));
  98. $aRow = $this->_ledger($aPending)[0];
  99. $this->assertSame('core-web', $aRow['platform']);
  100. $this->assertGreaterThan(0, (int)$aRow['session_id'], 'the buyer\'s visit');
  101. }
  102. public function testRecurring()
  103. {
  104. $aServer = $this->_server(['HTTP_X_NEO_PLATFORM' => 'web']);
  105. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 120);
  106. $aSubscription = ['subscription_id' => 'test-rev-sub-1', 'period' => 1, 'period_unit' => 'year', 'trial' => 0, 'status' => 'active'];
  107. $oBuyer = $this->_buyer($aServer);
  108. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  109. $this->_transaction($aPending, 120);
  110. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  111. // the renewal comes from cron
  112. $oCron = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 3600, ['api' => false, 'api_key' => false, 'logged' => 0]);
  113. $oCron->onPaymentAlert($this->_alert('prolong_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  114. $this->_transaction($aPending, 120, self::NOW + 3600);
  115. $oCron->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  116. $aRows = $this->_ledger($aPending);
  117. $this->assertSame([['sale', 'next-web'], ['renewal', 'core']], array_map(function ($a) { return [$a['kind'], $a['platform']]; }, $aRows));
  118. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aPending['id']);
  119. $this->assertSame(['active', 2, '10.0000', self::NOW, self::NOW + 3600, 'next-web'], [$aSbs['status'], (int)$aSbs['paid_count'], $aSbs['mrr_default'], (int)$aSbs['first_paid'], (int)$aSbs['last_paid'], $aSbs['platform']], 'a yearly 120 plan is 10 a month');
  120. // a trial: the payment module registers the order at the full price when the trial starts
  121. $aWeekly = $this->_order('recurring', 'paypal_api', 'USD', 3);
  122. $aTrial = ['subscription_id' => 'test-rev-sub-2', 'period' => 1, 'period_unit' => 'week', 'status' => 'trial', 'trial' => 7];
  123. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aWeekly, 'subscription' => $aTrial]));
  124. $this->_transaction($aWeekly, 3);
  125. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aWeekly]));
  126. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aWeekly['id']);
  127. $this->assertSame(['13.0000', 'trial', 0, 0], [$aSbs['mrr_default'], $aSbs['status'], (int)$aSbs['first_paid'], (int)$aSbs['paid_count']], 'a weekly 3 plan is 13 a month; the trial took no money');
  128. $this->assertSame(['trial'], array_column($this->_ledger($aWeekly), 'kind'));
  129. $aTiles = array_column($this->_oModule->getReports()->getRevenue($this->_filter(), [])['tiles'], 'value', 'key');
  130. $this->assertEquals([240.0, 1], [$aTiles['gross'], $aTiles['orders']], 'the trial is not revenue');
  131. $aReport = $this->_oModule->getReports()->getSubscriptions($this->_filter(), []);
  132. $aTiles = array_column($aReport['tiles'], 'value', 'key');
  133. $this->assertEquals(10.0, $aTiles['mrr'], 'a trial is not recurring revenue yet');
  134. $this->assertSame([1, 1, 1], [$aTiles['active'], $aTiles['trials'], $aTiles['new']]);
  135. $this->assertSame(1, array_sum(array_filter($aReport['movement']['datasets'][1]['values'])), 'one renewal');
  136. // the trial ends with the first charge
  137. $oEnd = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 7 * 86400, ['api' => false, 'api_key' => false, 'logged' => 0]);
  138. $this->_transaction($aWeekly, 3, self::NOW + 7 * 86400);
  139. $oEnd->onPaymentAlert($this->_alert('register_payment', ['pending' => $aWeekly]));
  140. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aWeekly['id']);
  141. $this->assertSame(['active', self::NOW + 7 * 86400, 1], [$aSbs['status'], (int)$aSbs['first_paid'], (int)$aSbs['paid_count']]);
  142. $this->assertSame(['trial', 'sale'], array_column($this->_ledger($aWeekly), 'kind'), 'the first charge after the trial is the sale');
  143. }
  144. public function testTrial()
  145. {
  146. // a weekly 3 plan with a week of trial, checked out in NEO web
  147. $aServer = $this->_server(['HTTP_X_NEO_PLATFORM' => 'web']);
  148. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 3);
  149. $aSubscription = ['subscription_id' => 'test-rev-sub-t', 'period' => 1, 'period_unit' => 'week', 'status' => 'trial', 'trial' => 7];
  150. $oBuyer = $this->_buyer($aServer);
  151. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  152. // the payment module registers the order at the full price when the trial starts
  153. $iRegistration = $this->_transaction($aPending, 3);
  154. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  155. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  156. $this->assertSame([['trial', '3.0000', 'next-web']], array_map(function ($a) { return [$a['kind'], $a['amount_default'], $a['platform']]; }, $this->_ledger($aPending)), 'kept for reference, once');
  157. $oReports = $this->_oModule->getReports();
  158. $aTiles = array_column($oReports->getRevenue($this->_filter(), [])['tiles'], 'value', 'key');
  159. $this->assertEquals([0.0, 0.0, 0, 0], [$aTiles['gross'], $aTiles['net'], $aTiles['orders'], $aTiles['customers']], 'no money yet');
  160. $aTiles = array_column($oReports->getSubscriptions($this->_filter(), [])['tiles'], 'value', 'key');
  161. $this->assertEquals([0.0, 0, 1], [$aTiles['mrr'], $aTiles['active'], $aTiles['trials']]);
  162. $this->assertSame([], $oReports->getLifetimeValues([self::CLIENT]), 'no lifetime value');
  163. // the trial start is an action of the visit, with no value
  164. $aEvents = BxDolDb::getInstance()->getAll("SELECT `name`, `value`, `platform` FROM `bx_analytics_events` WHERE `type` = 'una' AND `ts` < :to ORDER BY `id`", ['to' => self::NOW + 30 * 86400]);
  165. $this->assertEquals([['name' => 'trial_start', 'value' => 0, 'platform' => 'next-web']], $aEvents);
  166. // a seller cancelling the registration takes no money back
  167. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 60, ['logged' => self::SELLER]))->onPaymentAlert($this->_alert('cancel_order', ['type' => 'processed', 'order' => ['id' => $iRegistration]], self::SELLER));
  168. $this->assertCount(1, $this->_ledger($aPending));
  169. // the first charge when the trial ends, and a renewal a week later, both by cron
  170. foreach ([7, 14] as $iDays) {
  171. $oCron = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + $iDays * 86400, ['api' => false, 'api_key' => false, 'logged' => 0]);
  172. $oCron->onPaymentAlert($this->_alert('prolong_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  173. $this->_transaction($aPending, 3, self::NOW + $iDays * 86400);
  174. $oCron->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  175. }
  176. $aRows = $this->_ledger($aPending);
  177. $this->assertSame([['trial', 'next-web'], ['sale', 'next-web'], ['renewal', 'core']], array_map(function ($a) { return [$a['kind'], $a['platform']]; }, $aRows), 'the sale keeps the checkout platform');
  178. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aPending['id']);
  179. $this->assertSame(['active', self::NOW + 7 * 86400, self::NOW + 14 * 86400, 2], [$aSbs['status'], (int)$aSbs['first_paid'], (int)$aSbs['last_paid'], (int)$aSbs['paid_count']]);
  180. $oFilter = new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-09-08', 'to' => '2001-09-24'], self::NOW + 20 * 86400);
  181. $aRevenue = $oReports->getRevenue($oFilter, []);
  182. $aTiles = array_column($aRevenue['tiles'], 'value', 'key');
  183. $this->assertEquals([6.0, 6.0, 1, 1], [$aTiles['gross'], $aTiles['net'], $aTiles['orders'], $aTiles['customers']]);
  184. $this->assertEquals(6.0, array_sum(array_filter($aRevenue['chart']['datasets'][0]['values'])), 'the chart has the charges only');
  185. $aSubscriptions = $oReports->getSubscriptions($oFilter, []);
  186. $aTiles = array_column($aSubscriptions['tiles'], 'value', 'key');
  187. $this->assertEquals([13.0, 1, 0, 1], [$aTiles['mrr'], $aTiles['active'], $aTiles['trials'], $aTiles['new']]);
  188. $this->assertSame(1, array_sum(array_filter($aSubscriptions['movement']['datasets'][1]['values'])), 'one renewal: the first charge is the sale');
  189. $this->assertEquals([self::CLIENT => 6.0], array_map('floatval', $oReports->getLifetimeValues([self::CLIENT])));
  190. $aSales = $oReports->getSales($oFilter, []);
  191. $this->assertSame(['net' => 6.0, 'units' => 1, 'orders' => 1, 'renewals' => 1], $aSales['items']['rows'][0]['values']);
  192. $aEvents = BxDolDb::getInstance()->getPairs("SELECT `name`, SUM(`value`) AS `value` FROM `bx_analytics_events` WHERE `type` = 'una' AND `ts` < :to GROUP BY `name`", 'name', 'value', ['to' => self::NOW + 30 * 86400]);
  193. $this->assertEquals(['purchase' => 3, 'renewal' => 3, 'trial_start' => 0], $aEvents, 'the first charge is the purchase');
  194. // a refund during the trial takes nothing back; after it, the latest charge
  195. $aEarly = $this->_order('recurring', 'paypal_api', 'USD', 5);
  196. $this->_buyer($aServer)->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aEarly, 'subscription' => ['subscription_id' => 'test-rev-sub-t2', 'period' => 1, 'period_unit' => 'month', 'status' => 'trial', 'trial' => 14]]));
  197. $this->_transaction($aEarly, 5);
  198. $this->_buyer($aServer)->onPaymentAlert($this->_alert('register_payment', ['pending' => $aEarly]));
  199. $this->_deletePayment($aEarly);
  200. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 600, ['logged' => 0]))->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aEarly]));
  201. $this->assertSame(['trial'], array_column($this->_ledger($aEarly), 'kind'));
  202. $this->_deletePayment($aPending);
  203. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 15 * 86400, ['logged' => 0]))->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending]));
  204. $aRows = $this->_ledger($aPending);
  205. $this->assertSame(['trial', 'sale', 'renewal', 'refund'], array_column($aRows, 'kind'));
  206. $this->assertSame($aRows[2]['transaction_id'], $aRows[3]['transaction_id']);
  207. }
  208. public function testTrialImport()
  209. {
  210. // a weekly plan with a week of trial the ledger never saw: registered, charged, renewed
  211. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 3);
  212. $iFirst = $this->_transaction($aPending, 3, self::NOW - 20 * 86400);
  213. $this->_transaction($aPending, 3, self::NOW - 13 * 86400);
  214. $this->_transaction($aPending, 3, self::NOW - 6 * 86400);
  215. // transactions are read before subscriptions: the trial isn't known yet
  216. $oRevenue = $this->_oModule->getRevenue();
  217. $aPage = bx_srv_ic('bx_payment', 'get_transactions_after', [$iFirst - 1, 10], 'Orders');
  218. $this->assertSame(3, $oRevenue->importTransactions('bx_payment', $aPage));
  219. $this->assertSame(['sale', 'renewal', 'renewal'], array_column($this->_ledger($aPending), 'kind'));
  220. // its subscription brings the trial
  221. $aSubscription = [
  222. 'pending_id' => $aPending['id'], 'subscription_id' => 'test-rev-sub-ti', 'period' => 1, 'period_unit' => 'week', 'trial' => 7,
  223. 'date_add' => self::NOW - 20 * 86400, 'date_next' => self::NOW + 86400, 'status' => 'active', 'client_id' => self::CLIENT, 'seller_id' => self::SELLER,
  224. 'type' => 'recurring', 'provider' => 'stripe_v3', 'items' => self::SELLER . '_' . $this->_iModuleId . '_1_1', 'amount' => 3, 'currency' => 'USD',
  225. 'order' => $aPending['order'], 'data' => $aPending['data'],
  226. ];
  227. $this->assertSame(1, $oRevenue->importSubscriptions('bx_payment', [$aSubscription], false));
  228. $this->assertSame(['trial', 'sale', 'renewal'], array_column($this->_ledger($aPending), 'kind'));
  229. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aPending['id']);
  230. $this->assertSame([self::NOW - 13 * 86400, self::NOW - 6 * 86400, 2], [(int)$aSbs['first_paid'], (int)$aSbs['last_paid'], (int)$aSbs['paid_count']]);
  231. // read again, nothing new; read with the subscription known, the kinds are right at once
  232. $this->assertSame(0, $oRevenue->importTransactions('bx_payment', $aPage));
  233. BxDolDb::getInstance()->query("DELETE FROM `bx_analytics_revenue` WHERE `payment` = 'bx_payment' AND `pending_id` = :id", ['id' => (int)$aPending['id']]);
  234. $this->assertSame(3, $oRevenue->importTransactions('bx_payment', $aPage));
  235. $this->assertSame(['trial', 'sale', 'renewal'], array_column($this->_ledger($aPending), 'kind'));
  236. $oFilter = new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-08-19', 'to' => '2001-09-09'], self::NOW + 86400);
  237. $aTiles = array_column($this->_oModule->getReports()->getRevenue($oFilter, [])['tiles'], 'value', 'key');
  238. $this->assertEquals([6.0, 1], [$aTiles['gross'], $aTiles['orders']]);
  239. }
  240. public function testChargeWithAddons()
  241. {
  242. // the plan and an add-on: one transaction each, registered together
  243. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 12);
  244. $oBuyer = $this->_buyer($this->_server(['HTTP_X_NEO_PLATFORM' => 'web']));
  245. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => ['subscription_id' => 'test-rev-sub-a', 'period' => 1, 'period_unit' => 'month', 'status' => 'active']]));
  246. $this->_transaction($aPending, 10);
  247. $this->_transaction($aPending, 2);
  248. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  249. $oCron = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 3600, ['api' => false, 'api_key' => false, 'logged' => 0]);
  250. $this->_transaction($aPending, 10, self::NOW + 3600);
  251. $this->_transaction($aPending, 2, self::NOW + 3600);
  252. $oCron->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  253. $this->assertSame(['sale', 'sale', 'renewal', 'renewal'], array_column($this->_ledger($aPending), 'kind'), 'the add-on is part of the first charge');
  254. $this->assertSame(2, (int)$this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aPending['id'])['paid_count'], 'charges, not items');
  255. $aReport = $this->_oModule->getReports()->getSubscriptions($this->_filter(), []);
  256. $this->assertSame(1, array_sum(array_filter($aReport['movement']['datasets'][1]['values'])), 'one renewal');
  257. // a refund takes back the whole latest charge
  258. $this->_deletePayment($aPending);
  259. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 7200, ['logged' => 0]))->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending]));
  260. $aRows = $this->_ledger($aPending);
  261. $this->assertSame(['sale', 'sale', 'renewal', 'refund', 'renewal', 'refund'], array_column($aRows, 'kind'));
  262. $this->assertEquals(-12.0, array_sum(array_map(function ($a) { return $a['kind'] == 'refund' ? (float)$a['amount_default'] : 0; }, $aRows)));
  263. }
  264. public function testFirstSaleByWebhookKeepsTheCheckoutPlatform()
  265. {
  266. // the buyer checks out in the iOS app; the provider's webhook registers the first charge
  267. $aPending = $this->_order('recurring', 'paypal_api', 'USD', 5);
  268. $this->_buyer($this->_server(['HTTP_X_NEO_PLATFORM' => 'ios']))->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => ['subscription_id' => 'test-rev-sub-w', 'period' => 1, 'period_unit' => 'month', 'status' => 'unpaid']]));
  269. $this->_transaction($aPending, 5, self::NOW + 60);
  270. $oWebhook = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest(['REQUEST_METHOD' => 'POST', 'REMOTE_ADDR' => '173.0.82.126', 'HTTP_USER_AGENT' => 'PayPal/AUHD-214.0']), self::NOW + 60, ['api' => false, 'api_key' => false, 'logged' => 0]);
  271. $oWebhook->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending], 0));
  272. $aRow = $this->_ledger($aPending)[0];
  273. $this->assertSame(['sale', 'rn-ios', 0], [$aRow['kind'], $aRow['platform'], (int)$aRow['session_id']]);
  274. }
  275. public function testCancelledChargeIsNotImportedTwice()
  276. {
  277. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 9);
  278. $iFirst = $this->_transaction($aPending, 9);
  279. $oBuyer = $this->_buyer($this->_server());
  280. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => ['subscription_id' => 'test-rev-sub-r', 'period' => 1, 'period_unit' => 'month', 'status' => 'active']]));
  281. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  282. // the seller cancels the processed first charge: the payment module deletes it
  283. BxDolDb::getInstance()->query("DELETE FROM `bx_payment_transactions` WHERE `id` = :id", ['id' => $iFirst]);
  284. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 60, ['logged' => self::SELLER]))->onPaymentAlert($this->_alert('cancel_order', ['type' => 'processed', 'order' => ['id' => $iFirst]], self::SELLER));
  285. // the next charge is still a renewal
  286. $this->_transaction($aPending, 9, self::NOW + 3600);
  287. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 3600, ['api' => false, 'api_key' => false, 'logged' => 0]))->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  288. $this->assertSame(['sale', 'reversal', 'renewal'], array_column($this->_ledger($aPending), 'kind'));
  289. // importing the history again adds nothing
  290. $aPage = bx_srv_ic('bx_payment', 'get_transactions_after', [$iFirst - 1, 10], 'Orders');
  291. $this->assertSame(0, $this->_oModule->getRevenue()->importTransactions('bx_payment', $aPage));
  292. $this->assertCount(3, $this->_ledger($aPending));
  293. $aTiles = array_column($this->_oModule->getReports()->getRevenue($this->_filter(), [])['tiles'], 'value', 'key');
  294. $this->assertEquals([18.0, 9.0, 9.0], [$aTiles['gross'], $aTiles['refunds'], $aTiles['net']]);
  295. }
  296. public function testSingleRefund()
  297. {
  298. $aPending = $this->_order('single', 'stripe_v3', 'USD', 15);
  299. $this->_transaction($aPending, 15);
  300. $this->_buyer($this->_server())->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  301. // the payment module deletes the order before it says it was refunded
  302. $this->_deletePayment($aPending);
  303. $oWebhook = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 600, ['logged' => 0]);
  304. $oWebhook->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending]));
  305. $oWebhook->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending]));
  306. $aRows = $this->_ledger($aPending);
  307. $this->assertSame([['sale', '15.0000'], ['refund', '-15.0000']], array_map(function ($a) { return [$a['kind'], $a['amount_default']]; }, $aRows), 'a repeated alert adds nothing');
  308. $this->assertSame($aRows[0]['platform'], $aRows[1]['platform'], 'a refund keeps the sale\'s platform');
  309. $aTiles = array_column($this->_oModule->getReports()->getRevenue($this->_filter(), [])['tiles'], 'value', 'key');
  310. $this->assertEquals([15.0, 15.0, 0.0], [$aTiles['gross'], $aTiles['refunds'], $aTiles['net']]);
  311. }
  312. public function testRecurringRefundTakesBackTheLastCharge()
  313. {
  314. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 9);
  315. $this->_transaction($aPending, 9);
  316. $this->_transaction($aPending, 9, self::NOW + 60);
  317. $this->_buyer($this->_server())->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  318. $this->_deletePayment($aPending);
  319. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 600, ['logged' => 0]))->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending]));
  320. $aRows = $this->_ledger($aPending);
  321. $this->assertSame(['sale', 'renewal', 'refund'], array_column($aRows, 'kind'));
  322. $this->assertSame($aRows[1]['transaction_id'], $aRows[2]['transaction_id'], 'only the latest charge');
  323. }
  324. public function testCancelProcessedOrder()
  325. {
  326. $aPending = $this->_order('single', 'offline', 'USD', 40);
  327. $iTransaction = $this->_transaction($aPending, 40);
  328. $this->_buyer($this->_server())->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  329. $oSeller = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 60, ['logged' => self::SELLER]);
  330. $oSeller->onPaymentAlert($this->_alert('cancel_order', ['type' => 'pending', 'order' => ['id' => $aPending['id']]]));
  331. $this->assertCount(1, $this->_ledger($aPending), 'a pending order took no money');
  332. $oSeller->onPaymentAlert($this->_alert('cancel_order', ['type' => 'processed', 'order' => ['id' => $iTransaction, 'amount' => 40]]));
  333. $aRows = $this->_ledger($aPending);
  334. $this->assertSame([['sale', '40.0000'], ['reversal', '-40.0000']], array_map(function ($a) { return [$a['kind'], $a['amount']]; }, $aRows));
  335. }
  336. public function testRefundAfterReversal()
  337. {
  338. $aPending = $this->_order('single', 'stripe_v3', 'USD', 15);
  339. $iFirst = $this->_transaction($aPending, 10);
  340. $this->_transaction($aPending, 5);
  341. $this->_buyer($this->_server())->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  342. // the seller cancels one item, then the provider refunds the order
  343. BxDolDb::getInstance()->query("DELETE FROM `bx_payment_transactions` WHERE `id` = :id", ['id' => $iFirst]);
  344. $oOther = new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW + 60, ['logged' => 0]);
  345. $oOther->onPaymentAlert($this->_alert('cancel_order', ['type' => 'processed', 'order' => ['id' => $iFirst]], self::SELLER));
  346. $this->_deletePayment($aPending);
  347. $oOther->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending], 0));
  348. $oOther->onPaymentAlert($this->_alert('refund_payment', ['pending' => $aPending], 0));
  349. $aRows = $this->_ledger($aPending);
  350. $this->assertSame([['sale', '10.0000'], ['reversal', '-10.0000'], ['sale', '5.0000'], ['refund', '-5.0000']], array_map(function ($a) { return [$a['kind'], $a['amount']]; }, $aRows), 'nothing is taken back twice');
  351. }
  352. public function testPaidWithCredits()
  353. {
  354. $aPending = $this->_order('single', 'credits', 'USD', 12);
  355. $this->_transaction($aPending, 12);
  356. $this->_buyer($this->_server())->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  357. $this->assertSame(0, (int)$this->_ledger($aPending)[0]['cash']);
  358. $oReports = $this->_oModule->getReports();
  359. $aTiles = array_column($oReports->getRevenue($this->_filter(), [])['tiles'], 'value', 'key');
  360. $this->assertEquals(0.0, $aTiles['gross'], 'credits are not money');
  361. $aCredits = $oReports->getCredits($this->_filter(), []);
  362. $this->assertSame(1, count($aCredits['spent']['rows']));
  363. $this->assertEquals(12.0, $aCredits['spent']['rows'][0]['values']['net']);
  364. }
  365. public function testCancellationWithReason()
  366. {
  367. $aServer = $this->_server();
  368. $aIds = [];
  369. // reason (null: kept), paid days ago (0: within the reported day); `cancel` is the
  370. // payment module's default when no reason was given
  371. foreach ([['Too expensive', 20], ['Too expensive', 20], ['cancel', 20], ['', 20], ['Too expensive', 0], [null, 20]] as $i => list($sReason, $iDays)) {
  372. $iPaid = $iDays ? self::NOW - $iDays * 86400 : self::NOW - 3600;
  373. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 10);
  374. $aSubscription = ['subscription_id' => 'test-rev-sub-c' . $i, 'period' => 1, 'period_unit' => 'month', 'status' => 'active'];
  375. $oBuyer = $this->_buyer($aServer, 'core-web', $iPaid);
  376. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  377. $this->_transaction($aPending, 10, $iPaid);
  378. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  379. $aIds[] = [$aPending, $aSubscription, $sReason];
  380. }
  381. foreach ($aIds as list($aPending, $aSubscription, $sReason))
  382. if ($sReason !== null)
  383. $this->_buyer($aServer)->onPaymentAlert($this->_alert('cancel_subscription', ['pending' => $aPending, 'subscription' => $aSubscription, 'reason' => $sReason]));
  384. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aIds[0][0]['id']);
  385. $this->assertSame(['canceled', self::NOW, 'Too expensive'], [$aSbs['status'], (int)$aSbs['canceled'], $aSbs['reason']]);
  386. $aReport = $this->_oModule->getReports()->getSubscriptions($this->_filter(['from' => '2001-09-09', 'to' => '2001-09-09']), []);
  387. $aTiles = array_column($aReport['tiles'], 'value', 'key');
  388. $this->assertSame([5, 1, 1], [$aTiles['cancelled'], $aTiles['active'], $aTiles['new']]);
  389. $this->assertEquals(80.0, $aTiles['churn'], 'four of the five paying at the start; the one that started in the day is not churn');
  390. $this->assertSame(['Too expensive', _t('_bx_analytics_txt_sbs_no_reason')], array_column($aReport['reasons']['rows'], 'label'));
  391. $this->assertSame([3, 2], array_column(array_column($aReport['reasons']['rows'], 'values'), 'count'));
  392. }
  393. public function testUnpaidSubscriptionStopsPaying()
  394. {
  395. // monthly subscriptions whose charge failed: paid 200, 40 and 10 days ago
  396. $aIds = [];
  397. foreach ([200, 40, 10] as $i => $iDays) {
  398. $iPaid = self::NOW - $iDays * 86400;
  399. $aPending = $this->_order('recurring', 'credits', 'USD', 10);
  400. $aSubscription = ['subscription_id' => 'test-rev-sub-u' . $i, 'period' => 1, 'period_unit' => 'month', 'status' => 'active'];
  401. $oBuyer = $this->_buyer($this->_server(), 'core-web', $iPaid);
  402. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  403. $this->_transaction($aPending, 10, $iPaid);
  404. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  405. (new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest([]), self::NOW, ['logged' => 0]))->onPaymentAlert($this->_alert('overdue_subscription', ['pending' => $aPending, 'subscription' => $aSubscription]));
  406. $aIds[] = $aPending;
  407. }
  408. $this->assertSame('unpaid', $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aIds[0]['id'])['status']);
  409. // the first one's paid month and grace time ended in the period
  410. $aReport = $this->_oModule->getReports()->getSubscriptions($this->_filter(['from' => '2001-09-05', 'to' => '2001-09-10']), []);
  411. $aTiles = array_column($aReport['tiles'], 'value', 'key');
  412. $this->assertSame([1, 1], [$aTiles['active'], $aTiles['cancelled']]);
  413. $this->assertEquals([10.0, 50.0], [$aTiles['mrr'], $aTiles['churn']]);
  414. $this->assertSame(1, array_sum(array_filter($aReport['movement']['datasets'][2]['values'])));
  415. // the one that stopped paying months before isn't read at all: lapsed subscriptions
  416. // are never cancelled, and would otherwise be read by every report forever
  417. $aEnd = ['unit_seconds' => BxAnalyticsRevenue::getUnitSeconds(), 'unpaid' => BxAnalyticsRevenue::STATUS_UNPAID, 'grace' => BxAnalyticsRevenue::UNPAID_GRACE];
  418. $aRange = ['from' => self::NOW - 7 * 86400, 'to' => self::NOW, 'payment' => 'bx_payment', 'platform' => ''];
  419. $fPending = function ($aRows) { return array_map('intval', array_column($aRows, 'pending_id')); };
  420. $this->assertSame([(int)$aIds[1]['id'], (int)$aIds[2]['id']], $fPending($this->_oModule->_oDb->getSubscriptionRows($aRange, $aEnd)));
  421. $this->assertSame(array_map('intval', array_column($aIds, 'id')), $fPending($this->_oModule->_oDb->getSubscriptionRows($aRange)), 'without the rule');
  422. $aRange['from'] = self::NOW - 170 * 86400; // its paid month and grace time ended 162 days ago
  423. $this->assertSame(array_map('intval', array_column($aIds, 'id')), $fPending($this->_oModule->_oDb->getSubscriptionRows($aRange, $aEnd)), 'still paying in a longer period');
  424. }
  425. public function testProfileDeletionKeepsTheMoney()
  426. {
  427. (new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($this->_server(['HTTP_REFERER' => ''])), self::NOW - 600))->collect(['platform' => 'core-web', 'referrer' => 'https://www.google.com/', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/']]], ['profile_id' => self::CLIENT, 'platform' => 'core-web']);
  428. $aPending = $this->_order('single', 'stripe_v3', 'USD', 5);
  429. $this->_transaction($aPending, 5);
  430. $this->_buyer($this->_server(), 'core-web')->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  431. $aRow = $this->_ledger($aPending)[0];
  432. $this->assertSame(['search', 'search'], [$aRow['first_channel'], $aRow['last_channel']]);
  433. $this->_oModule->onProfileDelete(self::CLIENT);
  434. $aRow = $this->_ledger($aPending)[0];
  435. $this->assertSame([self::CLIENT, 0, '', ''], [(int)$aRow['client_id'], (int)$aRow['session_id'], $aRow['first_channel'], $aRow['last_channel']]);
  436. $this->assertSame('5.0000', $aRow['amount_default']);
  437. }
  438. public function testBackfill()
  439. {
  440. $CNF = &$this->_oModule->_oConfig->CNF;
  441. foreach ([$CNF['OPTION_BACKFILL'], $CNF['OPTION_BACKFILL_SBS']] as $sName)
  442. $this->_setOption($sName, getParam($sName));
  443. // past payments the ledger never saw: a subscription charged three times and a sale
  444. $aRecurring = $this->_order('recurring', 'stripe_v3', 'USD', 9);
  445. $iFirst = $this->_transaction($aRecurring, 9, self::NOW - 3 * 86400);
  446. $this->_transaction($aRecurring, 9, self::NOW - 2 * 86400);
  447. $aSingle = $this->_order('single', 'paypal_api', 'EUR', 20, ['cur_def' => 'USD', 'cur_rate' => 0.8]);
  448. $this->_transaction($aSingle, 20, self::NOW - 86400);
  449. $this->_transaction($aRecurring, 9, self::NOW - 3600);
  450. $oRevenue = $this->_oModule->getRevenue();
  451. // two pages: the renewal on the second page still knows the sale was on the first
  452. $aPage = bx_srv_ic('bx_payment', 'get_transactions_after', [$iFirst - 1, 2], 'Orders');
  453. $this->assertCount(2, $aPage);
  454. $this->assertSame(2, $oRevenue->importTransactions('bx_payment', $aPage));
  455. $aPage = bx_srv_ic('bx_payment', 'get_transactions_after', [(int)end($aPage)['id'], 10], 'Orders');
  456. $this->assertSame(2, $oRevenue->importTransactions('bx_payment', $aPage));
  457. $aRows = $this->_ledger($aRecurring);
  458. $this->assertSame(['sale', 'renewal', 'renewal'], array_column($aRows, 'kind'));
  459. $this->assertSame([self::NOW - 3 * 86400, ''], [(int)$aRows[0]['ts'], $aRows[0]['platform']], 'the time it was paid, no platform');
  460. $this->assertSame('25.0000', $this->_ledger($aSingle)[0]['amount_default']);
  461. // the payment module's current subscriptions, a page at a time
  462. $this->_paymentSubscription($aRecurring, 'test-rev-sub-b', self::NOW - 3 * 86400);
  463. $aPage = bx_srv_ic('bx_payment', 'get_subscriptions_after', [(int)$aRecurring['id'] - 1, 10, false], 'Subscriptions');
  464. $this->assertSame([(int)$aRecurring['id']], array_map('intval', array_column($aPage, 'pending_id')));
  465. $this->assertSame(1, $oRevenue->importSubscriptions('bx_payment', $aPage, false));
  466. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aRecurring['id']);
  467. $this->assertSame(['active', 3, self::NOW - 3 * 86400, 0], [$aSbs['status'], (int)$aSbs['paid_count'], (int)$aSbs['first_paid'], (int)$aSbs['canceled']]);
  468. // the whole run again from the same place: nothing new, and the cursors end
  469. setParam($CNF['OPTION_BACKFILL'], (string)($iFirst - 1));
  470. setParam($CNF['OPTION_BACKFILL_SBS'], 'deleted:' . (int)$aSingle['id']);
  471. $aStatus = $oRevenue->backfill(time() + 30);
  472. $this->assertSame('done', $aStatus['state']);
  473. $this->assertSame('-1', (string)getParam($CNF['OPTION_BACKFILL']));
  474. $this->assertSame('-1', (string)getParam($CNF['OPTION_BACKFILL_SBS']));
  475. $this->assertCount(3, $this->_ledger($aRecurring));
  476. // subscriptions take their payments from the ledger
  477. $this->assertSame(1, $oRevenue->importSubscriptions('bx_payment', [[
  478. 'pending_id' => $aRecurring['id'], 'subscription_id' => 'test-rev-sub-b', 'period' => 1, 'period_unit' => 'month', 'trial' => 0,
  479. 'date_add' => self::NOW - 3 * 86400, 'date_next' => 0, 'status' => 'active', 'client_id' => self::CLIENT, 'seller_id' => self::SELLER,
  480. 'type' => 'recurring', 'provider' => 'stripe_v3', 'items' => self::SELLER . '_' . $this->_iModuleId . '_1_1', 'amount' => 9, 'currency' => 'USD',
  481. 'order' => $aRecurring['order'], 'data' => $aRecurring['data'], 'reason' => 'too slow', 'deleted' => self::NOW,
  482. ]], true));
  483. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aRecurring['id']);
  484. $this->assertSame(['canceled', 3, self::NOW - 3 * 86400, self::NOW, 'too slow', '9.0000'], [$aSbs['status'], (int)$aSbs['paid_count'], (int)$aSbs['first_paid'], (int)$aSbs['canceled'], $aSbs['reason'], $aSbs['mrr_default']]);
  485. // the hourly cron continues an import that is running
  486. $this->_setOption('bx_analytics_retention_days', '36500');
  487. BxDolDb::getInstance()->query("DELETE FROM `bx_analytics_revenue` WHERE `payment` = 'bx_payment' AND `pending_id` = :id", ['id' => (int)$aSingle['id']]);
  488. setParam($CNF['OPTION_BACKFILL'], (string)($iFirst - 1));
  489. setParam($CNF['OPTION_BACKFILL_SBS'], 'deleted:' . (int)$aSingle['id']);
  490. $this->_oModule->servicePruning();
  491. $this->assertSame('-1', (string)getParam($CNF['OPTION_BACKFILL']));
  492. $this->assertCount(1, $this->_ledger($aSingle), 'imported again by the cron');
  493. }
  494. public function testPaymentModuleAlerts()
  495. {
  496. $aPending = $this->_order('recurring', 'stripe_v3', 'USD', 10);
  497. $oBuyer = $this->_buyer($this->_server());
  498. $oBuyer->onPaymentAlert($this->_alert('create_subscription', ['pending' => $aPending, 'subscription' => ['subscription_id' => 'test-rev-sub-p', 'period' => 1, 'period_unit' => 'month', 'status' => 'active']]));
  499. $this->_transaction($aPending, 10);
  500. $oBuyer->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  501. $this->_paymentSubscription($aPending, 'test-rev-sub-p', self::NOW);
  502. // the payment module's own code fires the alerts the ledger listens to
  503. $oSubscriptions = BxDolModule::getInstance('bx_payment')->getObjectSubscriptions();
  504. $this->assertTrue($oSubscriptions->overdue($aPending));
  505. $this->assertSame('unpaid', $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aPending['id'])['status'], 'a failed charge');
  506. $this->assertTrue($oSubscriptions->cancelLocal($aPending, 'Too slow'));
  507. $aSbs = $this->_oModule->_oDb->getSubscriptionRow('bx_payment', $aPending['id']);
  508. $this->assertSame(['canceled', 'Too slow'], [$aSbs['status'], $aSbs['reason']], 'the member\'s reason');
  509. $this->assertGreaterThan(0, (int)$aSbs['canceled']);
  510. }
  511. public function testSales()
  512. {
  513. $iSiteAdmin = (int)BxDolPayments::getInstance()->getOption('site_admin');
  514. // the site's own sale, a vendor's sale of two with a renewal, and a commission the vendor paid
  515. $this->_ledgerRow(1, ['kind' => 'sale', 'seller_id' => $iSiteAdmin, 'module' => 'test_rev_shop', 'item_id' => 7, 'item_count' => 1, 'amount' => 30]);
  516. $this->_ledgerRow(2, ['kind' => 'sale', 'seller_id' => self::SELLER, 'module' => 'test_rev_shop', 'item_id' => 8, 'item_count' => 2, 'amount' => 20, 'type' => 'recurring']);
  517. $this->_ledgerRow(3, ['kind' => 'renewal', 'pending_id' => 987650002, 'seller_id' => self::SELLER, 'module' => 'test_rev_shop', 'item_id' => 8, 'item_count' => 2, 'amount' => 20, 'type' => 'recurring', 'ts' => self::NOW + 60]);
  518. $this->_ledgerRow(4, ['kind' => 'sale', 'seller_id' => $iSiteAdmin, 'client_id' => self::SELLER, 'module' => 'bx_payment', 'module_id' => $this->_iModuleId, 'item_id' => 1, 'item_count' => 1, 'amount' => 5]);
  519. $oDb = BxDolDb::getInstance();
  520. foreach ([['paid', 5], ['unpaid', 7]] as $i => list($sStatus, $fAmount))
  521. $oDb->query("INSERT INTO `bx_payment_invoices` SET `name` = :name, `commissionaire_id` = :site, `committent_id` = :vendor, `amount` = :amount, `currency` = 'USD', `period_start` = :start, `period_end` = :date, `date_issue` = :date, `date_due` = :due, `status` = :status", [
  522. 'name' => 'test-rev-inv-' . $i, 'site' => $iSiteAdmin, 'vendor' => self::SELLER, 'amount' => $fAmount, 'start' => self::NOW - 30 * 86400, 'date' => self::NOW, 'due' => self::NOW + 7 * 86400, 'status' => $sStatus,
  523. ]);
  524. $aFilter = $this->_filter()->get();
  525. $aFilter['payment'] = 'bx_payment';
  526. $aSplit = $this->_oModule->_oDb->getRevenueSellers($aFilter, $iSiteAdmin);
  527. $this->assertEquals([30, 40, 5, 1], [(float)$aSplit['site'], (float)$aSplit['vendors'], (float)$aSplit['commissions'], (int)$aSplit['vendor_count']]);
  528. $aSales = $this->_oModule->getReports()->getSales($this->_filter(), []);
  529. $aTiles = array_column($aSales['tiles'], 'value', 'title');
  530. $this->assertEquals(75.0, $aTiles[_t('_bx_analytics_metric_revenue_net')]);
  531. $this->assertEquals(40.0, $aTiles[_t('_bx_analytics_metric_sales_vendors')]);
  532. $this->assertEquals(5.0, $aTiles[_t('_bx_analytics_metric_sales_commissions')]);
  533. $this->assertSame($iSiteAdmin > 0, isset($aTiles[_t('_bx_analytics_metric_sales_site')]), 'the site\'s own sales when its seller is set');
  534. $this->assertSame(['net' => 40.0, 'units' => 2, 'orders' => 1, 'renewals' => 1], $aSales['items']['rows'][0]['values']);
  535. $this->assertSame([[40.0, 1, 1], [35.0, 2, 2]], array_map(function ($a) { return array_values($a['values']); }, $aSales['sellers']['rows']));
  536. $this->assertSame([2, 1, 1, 0], array_column($aSales['invoices'], 'value'), 'issued, paid, unpaid, overdue');
  537. }
  538. public function testAccess()
  539. {
  540. // a sale, so the events report has a purchase to show
  541. $aPending = $this->_order('single', 'stripe_v3', 'USD', 10);
  542. $this->_transaction($aPending, 10);
  543. $this->_buyer($this->_server(['HTTP_X_NEO_PLATFORM' => 'ios']))->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  544. $sEvent = 'una|purchase|bx_payment';
  545. // a guest
  546. $this->bxBecomeGuest();
  547. $this->assertSame(403, $this->_oModule->serviceGetReport('revenue')['code']);
  548. $this->assertSame('', $this->_oModule->serviceGetBlockReport('revenue'));
  549. // a member whose level may use analytics but not see revenue, like a moderator by default
  550. $iProfile = (int)BxDolDb::getInstance()->getOne("SELECT `p`.`id` FROM `sys_profiles` AS `p` INNER JOIN `sys_accounts` AS `a` ON `a`.`id` = `p`.`account_id` WHERE (`a`.`role` & :admin) = 0 AND `p`.`type` <> 'system' AND `p`.`status` = 'active' ORDER BY `p`.`id` LIMIT 1", ['admin' => BX_DOL_ROLE_ADMIN]);
  551. if (!$iProfile)
  552. $this->markTestSkipped('no member profile to sign in as');
  553. $GLOBALS['glForceCurrentProfileId'] = $iProfile;
  554. $this->bxSetLoggedFlags(BX_DOL_ROLE_MEMBER);
  555. $this->assertSame($iProfile, (int)bx_get_logged_profile_id());
  556. $this->assertFalse(isAdmin());
  557. $iLevel = (int)BxDolAcl::getInstance()->getMemberMembershipInfo($iProfile)['id'];
  558. $this->_grant($iLevel, 'use analytics', true);
  559. $this->_grant($iLevel, 'view analytics revenue', false);
  560. $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowed());
  561. $this->assertNotSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedRevenue());
  562. $this->assertFalse($this->_oModule->getReportData('revenue', []));
  563. $this->assertSame('', $this->_oModule->serviceGetBlockReport('subscriptions'));
  564. $this->assertSame(403, $this->_oModule->serviceGetReport('sales')['code']);
  565. $this->assertNotContains('ltv', array_column($this->_oModule->getReports()->getUsers($this->_filter(), [])['columns'], 'key'), 'no lifetime value');
  566. $aDetail = $this->_oModule->getReports()->getEvents($this->_filter(), ['event' => $sEvent]);
  567. $this->assertSame(1, $aDetail['tiles'][0]['value']);
  568. $this->assertSame([], $aDetail['props'], 'a sale\'s properties are revenue');
  569. // with the revenue permission
  570. $this->_grant($iLevel, 'view analytics revenue', true);
  571. $this->assertSame(CHECK_ACTION_RESULT_ALLOWED, $this->_oModule->checkAllowedRevenue());
  572. $this->assertIsArray($this->_oModule->getReportData('revenue', []));
  573. $this->assertContains('ltv', array_column($this->_oModule->getReports()->getUsers($this->_filter(), [])['columns'], 'key'));
  574. $this->assertSame(['provider'], array_column($this->_oModule->getReports()->getEvents($this->_filter(), ['event' => $sEvent])['props'], 'caption'));
  575. }
  576. public function testCreditsDiscovery()
  577. {
  578. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_credits'))
  579. $this->markTestSkipped('bx_credits is not installed');
  580. $oDb = BxDolDb::getInstance();
  581. foreach ([['purchase', 'in', 100], ['checkout', 'out', 30], ['checkout', 'out', 20], ['grant', 'in', 5]] as $i => list($sType, $sDirection, $fAmount))
  582. $oDb->query("INSERT INTO `bx_credits_history` SET `first_pid` = :pid, `second_pid` = 0, `amount` = :amount, `type` = :type, `direction` = :direction, `order` = :order, `data` = '', `info` = '', `date` = :date", [
  583. 'pid' => self::CLIENT, 'amount' => $fAmount, 'type' => $sType, 'direction' => $sDirection, 'order' => 'test-rev-c' . $i, 'date' => self::NOW + $i,
  584. ]);
  585. $aFilter = $this->_filter()->get();
  586. $aSources = $this->_oModule->getReports()->getRevenueSources(['from' => $aFilter['from'], 'to' => $aFilter['to'], 'starts' => $aFilter['buckets'], 'limit' => 5], 'credits');
  587. $this->assertArrayHasKey('bx_credits', $aSources);
  588. $aMetrics = $aSources['bx_credits']['metrics'];
  589. $this->assertEquals([100, 50, 5], [$aMetrics['bought']['value'], $aMetrics['spent']['value'], $aMetrics['granted']['value']]);
  590. $this->assertEquals(50, array_sum($aMetrics['spent']['series']));
  591. $this->assertSame([['profile_id' => self::CLIENT, 'value' => 50.0]], $aSources['bx_credits']['lists']['top_spenders']['rows']);
  592. $this->assertArrayNotHasKey('bx_credits', $this->_oModule->getReports()->getRevenueSources(['from' => $aFilter['from'], 'to' => $aFilter['to']], 'money'), 'credits are not money');
  593. }
  594. /**
  595. * One checkout paid with credits, on both sides as a real one is: the credits module's
  596. * history and the ledger. The block's figures agree, with a platform selected too.
  597. */
  598. public function testCreditsBlockAgreesWithTheLedger()
  599. {
  600. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_credits'))
  601. $this->markTestSkipped('bx_credits is not installed');
  602. $aPending = $this->_order('single', 'credits', 'USD', 12);
  603. $this->_transaction($aPending, 12);
  604. $this->_creditsHistory('checkout', 'out', 12, $aPending['order']);
  605. $this->_buyer($this->_server())->onPaymentAlert($this->_alert('register_payment', ['pending' => $aPending]));
  606. $this->assertNotSame('rn-ios', $this->_ledger($aPending)[0]['platform']);
  607. $oReports = $this->_oModule->getReports();
  608. $sAllPlatforms = _t('_bx_analytics_txt_credits_all_platforms');
  609. $sNotInLedger = _t('_bx_analytics_txt_credits_not_in_ledger');
  610. // a period no other test reads: service results are kept for the request (BxDolService)
  611. $aCredits = $oReports->getCredits($this->_filter(['from' => '2001-09-07', 'to' => '2001-09-10']), []);
  612. $aTiles = array_column($aCredits['tiles'], 'value', 'key');
  613. $this->assertEquals(12.0, $aTiles['spent']);
  614. $this->assertSame([[12.0, 1]], array_map(function ($a) { return array_values($a['values']); }, $aCredits['spent']['rows']));
  615. $this->assertNotContains($sAllPlatforms, $aCredits['notes']);
  616. $this->assertNotContains($sNotInLedger, $aCredits['notes']);
  617. // another platform: the credits module can't tell, so the whole block is for all of them
  618. $oFilter = new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-09-07', 'to' => '2001-09-10', 'platform' => 'rn-ios'], self::NOW + 86400);
  619. $aCredits = $oReports->getCredits($oFilter, []);
  620. $aTiles = array_column($aCredits['tiles'], 'value', 'key');
  621. $this->assertEquals(12.0, $aTiles['spent']);
  622. $this->assertCount(1, $aCredits['spent']['rows'], 'the ledger part is not filtered by platform either');
  623. $this->assertContains($sAllPlatforms, $aCredits['notes']);
  624. }
  625. public function testCreditsSpentOutsideTheLedgerAreExplained()
  626. {
  627. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_credits'))
  628. $this->markTestSkipped('bx_credits is not installed');
  629. // a checkout from before the ledger, not imported
  630. $this->_creditsHistory('checkout', 'out', 25, 'test-rev-old');
  631. // a period no other test reads: service results are kept for the request (BxDolService)
  632. $aCredits = $this->_oModule->getReports()->getCredits($this->_filter(['from' => '2001-09-09', 'to' => '2001-09-10']), []);
  633. $aTiles = array_column($aCredits['tiles'], 'value', 'key');
  634. $this->assertEquals(25.0, $aTiles['spent']);
  635. $this->assertSame([], $aCredits['spent']['rows']);
  636. $this->assertSame(_t('_bx_analytics_txt_no_credit_orders'), $aCredits['spent']['empty']);
  637. $this->assertContains(_t('_bx_analytics_txt_credits_not_in_ledger'), $aCredits['notes']);
  638. }
  639. /*
  640. * Helpers
  641. */
  642. protected function _order($sType, $sProvider, $sCurrency, $fAmount, $aData = ['cur_def' => 'USD'])
  643. {
  644. $this->_iOrder++;
  645. $oDb = BxDolDb::getInstance();
  646. $oDb->query("INSERT INTO `bx_payment_transactions_pending` SET `client_id` = :client, `seller_id` = :seller, `type` = :type, `provider` = :provider, `items` = :items, `customs` = '', `amount` = :amount, `currency` = :currency, `order` = :order, `data` = :data, `date` = :date, `processed` = 1", [
  647. 'client' => self::CLIENT,
  648. 'seller' => self::SELLER,
  649. 'type' => $sType,
  650. 'provider' => $sProvider,
  651. 'items' => self::SELLER . '_' . $this->_iModuleId . '_1_1',
  652. 'amount' => $fAmount,
  653. 'currency' => $sCurrency,
  654. 'order' => 'test-rev-' . $this->_iOrder . '-' . mt_rand(),
  655. 'data' => serialize($aData),
  656. 'date' => self::NOW,
  657. ]);
  658. $iId = (int)$oDb->lastId();
  659. $this->_aPending[] = $iId;
  660. return $oDb->getRow("SELECT * FROM `bx_payment_transactions_pending` WHERE `id` = :id", ['id' => $iId]);
  661. }
  662. protected function _transaction($aPending, $fAmount, $iDate = self::NOW)
  663. {
  664. $oDb = BxDolDb::getInstance();
  665. $oDb->query("INSERT INTO `bx_payment_transactions` SET `pending_id` = :pending, `client_id` = :client, `seller_id` = :seller, `author_id` = :seller, `module_id` = :module, `item_id` = 1, `item_count` = 1, `amount` = :amount, `currency` = :currency, `license` = 'test-rev', `date` = :date, `new` = 0", [
  666. 'pending' => (int)$aPending['id'],
  667. 'client' => self::CLIENT,
  668. 'seller' => self::SELLER,
  669. 'module' => $this->_iModuleId,
  670. 'amount' => $fAmount,
  671. 'currency' => $aPending['currency'],
  672. 'date' => $iDate,
  673. ]);
  674. return (int)$oDb->lastId();
  675. }
  676. /**
  677. * The payment module's subscription of an order.
  678. */
  679. protected function _paymentSubscription($aPending, $sSubscriptionId, $iDate)
  680. {
  681. BxDolDb::getInstance()->query("INSERT INTO `bx_payment_subscriptions` SET `pending_id` = :pending, `customer_id` = 'test-rev', `subscription_id` = :subscription, `period` = 1, `period_unit` = 'month', `trial` = 0, `date_add` = :date, `date_next` = :next, `status` = 'active'", [
  682. 'pending' => (int)$aPending['id'],
  683. 'subscription' => $sSubscriptionId,
  684. 'date' => $iDate,
  685. 'next' => $iDate + 30 * 86400,
  686. ]);
  687. }
  688. /**
  689. * A ledger row written directly: `$i` makes its order, pending order and transaction.
  690. */
  691. protected function _ledgerRow($i, $aRow)
  692. {
  693. $aRow = array_merge([
  694. 'ts' => self::NOW, 'cash' => 1, 'payment' => 'bx_payment', 'pending_id' => 987650000 + $i, 'transaction_id' => 987650000 + $i, 'order' => 'test-rev-l' . $i,
  695. 'module_id' => 0, 'client_id' => self::CLIENT, 'author_id' => 0, 'provider' => 'stripe_v3', 'type' => 'single', 'currency' => 'USD', 'rate' => 1, 'platform' => 'core-web',
  696. ], $aRow);
  697. $aRow['amount_default'] = $aRow['amount'];
  698. $this->assertSame(1, $this->_oModule->_oDb->insertRevenue($aRow));
  699. }
  700. /**
  701. * A row of the credits module's history, for the test client; `$sOrder` must start with
  702. * `test-rev-` for the cleanup.
  703. */
  704. protected function _creditsHistory($sType, $sDirection, $fAmount, $sOrder, $iDate = self::NOW)
  705. {
  706. BxDolDb::getInstance()->query("INSERT INTO `bx_credits_history` SET `first_pid` = :pid, `second_pid` = 0, `amount` = :amount, `type` = :type, `direction` = :direction, `order` = :order, `data` = '', `info` = '', `date` = :date", [
  707. 'pid' => self::CLIENT, 'amount' => $fAmount, 'type' => $sType, 'direction' => $sDirection, 'order' => $sOrder, 'date' => $iDate,
  708. ]);
  709. }
  710. protected function _grant($iLevel, $sAction, $bAllowed)
  711. {
  712. $oDb = BxDolDb::getInstance();
  713. $iAction = (int)$oDb->getOne("SELECT `ID` FROM `sys_acl_actions` WHERE `Module` = 'bx_analytics' AND `Name` = :name", ['name' => $sAction]);
  714. $aBindings = ['level' => (int)$iLevel, 'action' => $iAction];
  715. $sKey = $iLevel . ':' . $iAction;
  716. if (!array_key_exists($sKey, $this->_aMatrix))
  717. $this->_aMatrix[$sKey] = $oDb->getRow("SELECT * FROM `sys_acl_matrix` WHERE `IDLevel` = :level AND `IDAction` = :action", $aBindings);
  718. $oDb->query("DELETE FROM `sys_acl_matrix` WHERE `IDLevel` = :level AND `IDAction` = :action", $aBindings);
  719. if ($bAllowed)
  720. $oDb->query("INSERT INTO `sys_acl_matrix` SET `IDLevel` = :level, `IDAction` = :action", $aBindings);
  721. $oDb->cleanMemoryAll();
  722. }
  723. protected function _restoreMatrix()
  724. {
  725. $oDb = BxDolDb::getInstance();
  726. foreach ($this->_aMatrix as $sKey => $aRow) {
  727. list($iLevel, $iAction) = explode(':', $sKey);
  728. $oDb->query("DELETE FROM `sys_acl_matrix` WHERE `IDLevel` = :level AND `IDAction` = :action", ['level' => (int)$iLevel, 'action' => (int)$iAction]);
  729. if (!empty($aRow))
  730. $oDb->query("INSERT INTO `sys_acl_matrix` SET " . $oDb->arrayToSQL(array_filter($aRow, function ($m) { return $m !== null; })));
  731. }
  732. $this->_aMatrix = [];
  733. $oDb->cleanMemoryAll();
  734. }
  735. protected function _deletePayment($aPending)
  736. {
  737. $oDb = BxDolDb::getInstance();
  738. $oDb->query("DELETE FROM `bx_payment_transactions` WHERE `pending_id` = :id", ['id' => (int)$aPending['id']]);
  739. $oDb->query("DELETE FROM `bx_payment_transactions_pending` WHERE `id` = :id", ['id' => (int)$aPending['id']]);
  740. }
  741. protected function _ledger($aPending)
  742. {
  743. return BxDolDb::getInstance()->getAll("SELECT * FROM `bx_analytics_revenue` WHERE `payment` = 'bx_payment' AND `pending_id` = :id ORDER BY `transaction_id`, `id`", ['id' => (int)$aPending['id']]);
  744. }
  745. protected function _alert($sAction, $aExtras, $iSender = self::CLIENT)
  746. {
  747. return new BxDolAlerts('system', $sAction, 0, $iSender, array_merge(['module' => 'bx_payment'], $aExtras));
  748. }
  749. /**
  750. * The handler in the buyer's own request: through api.php from NEO, or the classic UI.
  751. */
  752. protected function _buyer($aServer, $sPlatform = 'neo', $iNow = self::NOW)
  753. {
  754. return new BxAnalyticsRevenue($this->_oModule, new BxAnalyticsRequest($aServer), $iNow, ['api' => $sPlatform == 'neo', 'api_key' => false, 'logged' => self::CLIENT]);
  755. }
  756. protected function _server($aExtra = [])
  757. {
  758. return array_merge(['REQUEST_METHOD' => 'POST', 'REMOTE_ADDR' => '81.2.69.142', 'HTTP_USER_AGENT' => self::UA_SAFARI], $aExtra);
  759. }
  760. protected function _filter($aDates = ['from' => '2001-09-08', 'to' => '2001-09-10'])
  761. {
  762. return new BxAnalyticsFilter($this->_oModule, ['range' => 'custom'] + $aDates, self::NOW + 86400);
  763. }
  764. protected function _setOption($sName, $sValue)
  765. {
  766. if (!array_key_exists($sName, $this->_aOptions))
  767. $this->_aOptions[$sName] = getParam($sName);
  768. setParam($sName, $sValue);
  769. }
  770. protected function _cleanup()
  771. {
  772. $oDb = BxDolDb::getInstance();
  773. $iTo = self::NOW + 30 * 86400;
  774. $aPending = array_merge($this->_aPending, $oDb->getColumn("SELECT `id` FROM `bx_payment_transactions_pending` WHERE `order` LIKE 'test-rev-%'"));
  775. if (!empty($aPending)) {
  776. $oDb->query("DELETE FROM `bx_payment_transactions` WHERE `pending_id` IN (" . $oDb->implode_escape($aPending) . ")");
  777. $oDb->query("DELETE FROM `bx_payment_transactions_pending` WHERE `id` IN (" . $oDb->implode_escape($aPending) . ")");
  778. }
  779. $oDb->query("DELETE FROM `bx_payment_transactions` WHERE `license` = 'test-rev'");
  780. $oDb->query("DELETE FROM `bx_payment_subscriptions` WHERE `customer_id` = 'test-rev'");
  781. $oDb->query("DELETE FROM `bx_payment_subscriptions_deleted` WHERE `customer_id` = 'test-rev'");
  782. $oDb->query("DELETE FROM `bx_payment_invoices` WHERE `name` LIKE 'test-rev-%'");
  783. $oDb->query("DELETE FROM `bx_analytics_revenue` WHERE `ts` < :to OR `order` LIKE 'test-rev-%'", ['to' => $iTo]);
  784. $oDb->query("DELETE FROM `bx_analytics_subscriptions` WHERE `started` < :to OR `subscription_id` LIKE 'test-rev-%'", ['to' => $iTo]);
  785. $oDb->query("DELETE FROM `bx_analytics_events` WHERE `ts` < :to", ['to' => $iTo]);
  786. $oDb->query("DELETE FROM `bx_analytics_sessions` WHERE `started` < :to", ['to' => $iTo]);
  787. $oDb->query("DELETE FROM `bx_analytics_salts` WHERE `day` < '2002-01-01'");
  788. $oDb->query("DELETE FROM `bx_analytics_limits` WHERE `bucket` < 1009843200");
  789. $this->_oModule->cleanReportCache(); // reports of the test data cached by the run before
  790. if ($oDb->getOne("SHOW TABLES LIKE 'bx_credits_history'"))
  791. $oDb->query("DELETE FROM `bx_credits_history` WHERE `order` LIKE 'test-rev-%'");
  792. $this->_aPending = [];
  793. }
  794. }