BxAnalyticsRevenue.php 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup Analytics Analytics
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. /**
  12. * The revenue ledger. Listens to the alerts every payment module fires on the `system` unit
  13. * and writes one row per money movement: a sale, a renewal, a refund or a reversal of a
  14. * processed order. Amounts are what the payment module records, also converted to the site's
  15. * default currency with the rate the order was placed at. Subscriptions are kept beside it,
  16. * with their price per month (MRR).
  17. *
  18. * A subscription with a trial is registered by the payment module when the trial starts, with
  19. * transactions at the full price although nothing was charged. Those rows are kept with the
  20. * kind `trial` (amounts for reference only): no revenue sum, count or chart reads them, they
  21. * are never refunded or reversed, and they make a `trial_start` action with no value instead
  22. * of a purchase. The first charge after the trial is the order's sale, the later ones renewals.
  23. *
  24. * A movement the buyer's own request makes carries the buyer's platform, visit and traffic
  25. * sources; a webhook or a cron job is the platform `core`. Money is always written; the visit
  26. * and the sources only when the request could be tracked.
  27. *
  28. * Past payments are read in steps through the payment module's paged services (backfill).
  29. */
  30. class BxAnalyticsRevenue
  31. {
  32. const KIND_SALE = 'sale';
  33. const KIND_RENEWAL = 'renewal';
  34. const KIND_REFUND = 'refund';
  35. const KIND_REVERSAL = 'reversal';
  36. const KIND_TRIAL = 'trial';
  37. const TYPE_SINGLE = 'single';
  38. const TYPE_RECURRING = 'recurring';
  39. const STATUS_TRIAL = 'trial';
  40. const STATUS_ACTIVE = 'active';
  41. const STATUS_UNPAID = 'unpaid';
  42. const STATUS_CANCELED = 'canceled';
  43. const PROVIDER_CREDITS = 'credits';
  44. const PROVIDER_APPLE = 'apple_in_app';
  45. /**
  46. * A payment registered further than this from now is history the alert brought along
  47. * (renewals of an order recorded before analytics was installed), not something the
  48. * current request did.
  49. */
  50. const LIVE_WINDOW = 86400;
  51. /**
  52. * How long an unpaid subscription still counts as paying after its paid period ends: the
  53. * payment module retries a failed charge for a few days and never cancels it by itself.
  54. */
  55. const UNPAID_GRACE = 604800;
  56. /**
  57. * Subscription period units per month, for MRR.
  58. */
  59. protected static $_aMonthFactors = [
  60. 'year' => 1 / 12,
  61. 'month' => 1,
  62. 'week' => 52 / 12,
  63. 'day' => 365.25 / 12,
  64. 'hour' => 730.5,
  65. 'minute' => 43830,
  66. ];
  67. /**
  68. * Subscription period units in seconds.
  69. */
  70. protected static $_aUnitSeconds = [
  71. 'year' => 31557600,
  72. 'month' => 2629800,
  73. 'week' => 604800,
  74. 'day' => 86400,
  75. 'hour' => 3600,
  76. 'minute' => 60,
  77. ];
  78. protected $_oModule;
  79. protected $_oConfig;
  80. protected $_oDb;
  81. protected $_oRequest;
  82. protected $_iNow;
  83. /**
  84. * What the request knows that tests set instead: `api` (the request came through
  85. * api.php), `api_key` (with an accepted API key), `logged` (the signed-in profile).
  86. */
  87. protected $_aOptions;
  88. protected $_oCollector = null;
  89. protected $_sDefaultCurrency = null;
  90. protected $_aModuleNames = [];
  91. protected $_aSources = [];
  92. public function __construct($oModule, $oRequest = null, $iNow = null, $aOptions = [])
  93. {
  94. $this->_oModule = $oModule;
  95. $this->_oConfig = $oModule->_oConfig;
  96. $this->_oDb = $oModule->_oDb;
  97. $this->_oRequest = $oRequest ? $oRequest : new BxAnalyticsRequest();
  98. $this->_iNow = $iNow ? (int)$iNow : time();
  99. $this->_aOptions = is_array($aOptions) ? $aOptions : [];
  100. }
  101. /*
  102. * Pure helpers
  103. */
  104. /**
  105. * How many of a period unit make a month, or null for a unit the payment module doesn't use.
  106. */
  107. public static function getMonthFactor($sUnit)
  108. {
  109. return isset(self::$_aMonthFactors[$sUnit]) ? self::$_aMonthFactors[$sUnit] : null;
  110. }
  111. /**
  112. * Monthly recurring revenue of a price charged every `$iPeriod` units, or null when the
  113. * price or the unit is unknown.
  114. */
  115. public static function getMrr($fAmount, $iPeriod, $sUnit)
  116. {
  117. $fFactor = self::getMonthFactor($sUnit);
  118. if($fAmount === null || $fFactor === null)
  119. return null;
  120. return round((float)$fAmount * $fFactor / max(1, (int)$iPeriod), 4);
  121. }
  122. /**
  123. * Seconds per subscription period unit: [unit => seconds].
  124. */
  125. public static function getUnitSeconds()
  126. {
  127. return self::$_aUnitSeconds;
  128. }
  129. /**
  130. * Length of a subscription period in seconds, or null for a unit the payment module doesn't use.
  131. */
  132. public static function getPeriodSeconds($iPeriod, $sUnit)
  133. {
  134. return isset(self::$_aUnitSeconds[$sUnit]) ? max(1, (int)$iPeriod) * self::$_aUnitSeconds[$sUnit] : null;
  135. }
  136. /**
  137. * A charge of a subscription with a trial is its registration when it came in the first
  138. * half of the trial: the payment module registers the order when the trial starts, and
  139. * the first real charge comes when it ends.
  140. *
  141. * @param array $aSubscription ledger subscription row: started, trial (days)
  142. */
  143. public static function isTrialRegistration($aSubscription, $iTs)
  144. {
  145. $iTrial = !empty($aSubscription['trial']) ? (int)$aSubscription['trial'] : 0;
  146. return $iTrial > 0 && (int)$iTs < self::getTrialPaidFrom($aSubscription);
  147. }
  148. /**
  149. * The time from which charges of a subscription are paid ones, see isTrialRegistration().
  150. */
  151. public static function getTrialPaidFrom($aSubscription)
  152. {
  153. $iTrial = !empty($aSubscription['trial']) ? (int)$aSubscription['trial'] : 0;
  154. return $iTrial > 0 ? (int)$aSubscription['started'] + $iTrial * 43200 : 0;
  155. }
  156. /**
  157. * The rate of an order's currency against the default currency, from the snapshot the
  158. * payment module stores with the order (`data`: serialized cur_def and cur_rate): 1 for
  159. * the default currency (today's, whatever it was when the order was placed), the
  160. * snapshot's rate, or null when the snapshot doesn't tell (no rate, or the site's default
  161. * currency has changed since).
  162. */
  163. public static function getSnapshotRate($sCurrency, $sData, $sDefault)
  164. {
  165. if($sCurrency === '' || $sCurrency === $sDefault)
  166. return 1.0;
  167. $aData = is_string($sData) && $sData !== '' ? @unserialize($sData, ['allowed_classes' => false]) : [];
  168. if(!is_array($aData))
  169. $aData = [];
  170. $sSnapshotDefault = !empty($aData['cur_def']) && is_string($aData['cur_def']) ? $aData['cur_def'] : $sDefault;
  171. if($sSnapshotDefault !== $sDefault)
  172. return null;
  173. if(!empty($aData['cur_rate']) && is_numeric($aData['cur_rate']) && (float)$aData['cur_rate'] > 0)
  174. return (float)$aData['cur_rate'];
  175. return null;
  176. }
  177. /**
  178. * An amount in the default currency, or null without a rate. Rates are quoted against the
  179. * default currency, so the order's amount is divided by its rate.
  180. */
  181. public static function toDefault($fAmount, $mixedRate)
  182. {
  183. if($mixedRate === null || (float)$mixedRate <= 0)
  184. return null;
  185. return round((float)$fAmount / (float)$mixedRate, 4);
  186. }
  187. /**
  188. * A charge of a recurring order is its sale when it is the order's first charge and a
  189. * renewal otherwise; every transaction of a single order is a sale. A charge is the
  190. * transactions one registration wrote (one per item, add-ons included): they share their time.
  191. *
  192. * @param int $iChargeTs time of the transaction
  193. * @param int $iFirstTs time of the order's first charge
  194. */
  195. public static function getKind($sType, $iChargeTs, $iFirstTs)
  196. {
  197. return $sType == self::TYPE_RECURRING && (int)$iChargeTs != (int)$iFirstTs ? self::KIND_RENEWAL : self::KIND_SALE;
  198. }
  199. /**
  200. * The kind of a charge, trials included: a charge of a recurring order that registered its
  201. * trial (see isTrialRegistration()) is a `trial`, any other follows getKind() with the
  202. * order's first paid charge.
  203. *
  204. * @param array $aSubscription ledger subscription row (started, trial), [] for none
  205. * @param int $iFirstPaidTs time of the order's first charge that is not a trial registration
  206. */
  207. public static function getChargeKind($sType, $aSubscription, $iChargeTs, $iFirstPaidTs)
  208. {
  209. if($sType == self::TYPE_RECURRING && self::isTrialRegistration($aSubscription, $iChargeTs))
  210. return self::KIND_TRIAL;
  211. return self::getKind($sType, $iChargeTs, $iFirstPaidTs);
  212. }
  213. /**
  214. * The action a charge of the current request records: a purchase, a renewal, or the start
  215. * of a trial (with no value, it took no money).
  216. */
  217. public static function getActionName($sKind)
  218. {
  219. switch($sKind) {
  220. case self::KIND_RENEWAL:
  221. return 'renewal';
  222. case self::KIND_TRIAL:
  223. return 'trial_start';
  224. }
  225. return 'purchase';
  226. }
  227. /**
  228. * Kinds of an order's charges, from its ledger rows (sale, renewal and trial rows; refund and
  229. * reversal rows are read to leave the charges they negate as they are) and its subscription:
  230. * [row id => kind] for the rows whose kind differs. Used when the subscription becomes known
  231. * after its charges were read (the import reads transactions before subscriptions).
  232. */
  233. public static function getKindChanges($sType, $aSubscription, $aRows)
  234. {
  235. $aNegated = [];
  236. $iFirst = 0;
  237. foreach($aRows as $aRow) {
  238. if(in_array($aRow['kind'], [self::KIND_REFUND, self::KIND_REVERSAL], true))
  239. $aNegated[(int)$aRow['transaction_id']] = true;
  240. else if($sType != self::TYPE_RECURRING || !self::isTrialRegistration($aSubscription, (int)$aRow['ts']))
  241. $iFirst = $iFirst ? min($iFirst, (int)$aRow['ts']) : (int)$aRow['ts'];
  242. }
  243. $aChanges = [];
  244. foreach($aRows as $aRow) {
  245. if(!in_array($aRow['kind'], [self::KIND_SALE, self::KIND_RENEWAL, self::KIND_TRIAL], true) || isset($aNegated[(int)$aRow['transaction_id']]))
  246. continue;
  247. $sKind = self::getChargeKind($sType, $aSubscription, (int)$aRow['ts'], $iFirst);
  248. if($sKind != $aRow['kind'])
  249. $aChanges[(int)$aRow['id']] = $sKind;
  250. }
  251. return $aChanges;
  252. }
  253. /**
  254. * Module and item of an order's first item, from the payment module's item descriptors
  255. * (vendor_module_item_count, joined with ":").
  256. *
  257. * @return array [module id, item id]
  258. */
  259. public static function getFirstItem($sItems)
  260. {
  261. $aItems = explode(':', (string)$sItems);
  262. $aParts = explode('_', (string)reset($aItems));
  263. return [isset($aParts[1]) ? (int)$aParts[1] : 0, isset($aParts[2]) ? (int)$aParts[2] : 0];
  264. }
  265. /*
  266. * Alerts
  267. */
  268. /**
  269. * A payment alert on the `system` unit: extras carry the payment module's name.
  270. */
  271. public function onPaymentAlert($oAlert)
  272. {
  273. $aExtras = is_array($oAlert->aExtras) ? $oAlert->aExtras : [];
  274. $sPayment = isset($aExtras['module']) && is_string($aExtras['module']) && preg_match('/^[a-z0-9_]{1,32}$/', $aExtras['module']) ? $aExtras['module'] : '';
  275. if($sPayment === '')
  276. return false;
  277. $aPending = isset($aExtras['pending']) && is_array($aExtras['pending']) ? $aExtras['pending'] : [];
  278. $aSubscription = isset($aExtras['subscription']) && is_array($aExtras['subscription']) ? $aExtras['subscription'] : [];
  279. switch($oAlert->sAction) {
  280. case 'register_payment':
  281. return $this->registerPayment($sPayment, $aPending);
  282. case 'refund_payment':
  283. return $this->refundPayment($sPayment, $aPending);
  284. case 'create_subscription':
  285. return $this->createSubscription($sPayment, $aPending, $aSubscription);
  286. case 'prolong_subscription':
  287. return $this->setSubscriptionStatus($sPayment, $aPending, self::STATUS_ACTIVE);
  288. case 'overdue_subscription':
  289. return $this->setSubscriptionStatus($sPayment, $aPending, self::STATUS_UNPAID);
  290. case 'cancel_subscription':
  291. return $this->cancelSubscription($sPayment, $aPending, $aSubscription, isset($aExtras['reason']) && is_string($aExtras['reason']) ? $aExtras['reason'] : '');
  292. case 'cancel_order':
  293. // a pending order never took money
  294. if(isset($aExtras['type']) && $aExtras['type'] == 'processed' && isset($aExtras['order']) && is_array($aExtras['order']))
  295. return $this->reverseTransaction($sPayment, $aExtras['order']);
  296. return false;
  297. case 'finalize_checkout':
  298. return $this->finalizeCheckout($sPayment, $aPending);
  299. }
  300. return false;
  301. }
  302. /**
  303. * An order was paid, a subscription charged or its trial registered: every transaction of
  304. * the order the ledger doesn't have yet. A transaction the current request made gets its
  305. * context and becomes a purchase, renewal or trial_start event, so goals and funnels can
  306. * end in a sale or a trial.
  307. *
  308. * @return int movements added
  309. */
  310. public function registerPayment($sPayment, $aPending)
  311. {
  312. $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
  313. if(!$iPendingId)
  314. return 0;
  315. // paid before joining: the order gets its client when the account is created
  316. if(empty($aPending['client_id']) || !isset($aPending['type'])) {
  317. $aFresh = $this->_getPendingOrder($sPayment, $iPendingId);
  318. if(!empty($aFresh))
  319. $aPending = $aFresh;
  320. }
  321. $aTransactions = $this->_getTransactions($sPayment, $iPendingId);
  322. if(empty($aTransactions))
  323. return 0;
  324. $aPending['client_id'] = isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0;
  325. $sType = isset($aPending['type']) ? $aPending['type'] : self::TYPE_SINGLE;
  326. $fRate = $this->getRate(isset($aPending['currency']) ? (string)$aPending['currency'] : '', isset($aPending['data']) ? $aPending['data'] : '');
  327. // a renewal brings the whole order along: skip the charges the ledger has
  328. $aRows = $this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_SALE, self::KIND_RENEWAL, self::KIND_TRIAL]);
  329. $aKnown = array_flip(array_map('intval', array_column($aRows, 'transaction_id')));
  330. $aNew = [];
  331. foreach($aTransactions as $aTransaction)
  332. if(!isset($aKnown[(int)$aTransaction['id']]))
  333. $aNew[] = $aTransaction;
  334. if(empty($aNew))
  335. return 0;
  336. // the subscription tells a trial's registration from a charge: its row is made first
  337. // when the subscription began before analytics was installed. A subscription's first
  338. // sale a webhook registers keeps the platform of the buyer's checkout.
  339. $aSubscription = [];
  340. if($sType == self::TYPE_RECURRING && $this->_ensureSubscription($sPayment, $aPending))
  341. $aSubscription = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
  342. $sCheckoutPlatform = !empty($aSubscription['platform']) ? (string)$aSubscription['platform'] : '';
  343. // the order's first paid charge is the earliest one either side knows (the payment
  344. // module deletes a transaction its seller cancels, the ledger keeps it); the
  345. // registration of a trial is not one
  346. $iFirst = 0;
  347. foreach($aRows as $aRow)
  348. if($aRow['kind'] != self::KIND_TRIAL)
  349. $iFirst = $iFirst ? min($iFirst, (int)$aRow['ts']) : (int)$aRow['ts'];
  350. foreach($aNew as $aTransaction) {
  351. $iTs = !empty($aTransaction['date']) ? (int)$aTransaction['date'] : $this->_iNow;
  352. if(self::getChargeKind($sType, $aSubscription, $iTs, $iTs) != self::KIND_TRIAL)
  353. $iFirst = $iFirst ? min($iFirst, $iTs) : $iTs;
  354. }
  355. $iAdded = 0;
  356. $aLive = [];
  357. $aPaid = [];
  358. foreach($aNew as $aTransaction) {
  359. $iTs = !empty($aTransaction['date']) ? (int)$aTransaction['date'] : $this->_iNow;
  360. $sKind = self::getChargeKind($sType, $aSubscription, $iTs, $iFirst);
  361. $bLive = abs($this->_iNow - $iTs) <= self::LIVE_WINDOW;
  362. // a trial starts at the checkout, like a sale
  363. $sContextKind = $sKind == self::KIND_TRIAL ? self::KIND_SALE : $sKind;
  364. $aContext = $bLive ? $this->getContext((int)$aPending['client_id'], isset($aPending['provider']) ? $aPending['provider'] : '', $sContextKind, $iTs, $sContextKind == self::KIND_SALE ? $sCheckoutPlatform : '') : $this->_getImportContext((int)$aPending['client_id'], $iTs);
  365. $aRow = $this->_getLedgerRow($sPayment, $aPending, $aTransaction, $sKind, $iTs, $fRate, $aContext);
  366. if(!$this->_oDb->insertRevenue($aRow))
  367. continue;
  368. $iAdded++;
  369. if($sKind != self::KIND_TRIAL)
  370. $aPaid[$iTs] = $iTs;
  371. if($bLive)
  372. $aLive[] = [$aRow, $aContext];
  373. }
  374. // the registration of a trial took no money: the subscription pays from its first charge
  375. if(!empty($aPaid) && $sType == self::TYPE_RECURRING)
  376. $this->_oDb->addSubscriptionPayments($sPayment, $iPendingId, count($aPaid), min($aPaid), max($aPaid));
  377. // no amounts or order numbers in the properties: the events report shows them to
  378. // everyone who may use analytics, revenue or not
  379. foreach($aLive as list($aRow, $aContext))
  380. if($aContext['track'])
  381. $this->_getCollector()->collectAction(self::getActionName($aRow['kind']), $aRow['module'], $aRow['item_id'], $aRow['client_id'], [
  382. 'platform' => $aContext['platform'],
  383. 'api_key' => $this->_isApiKey(),
  384. // decided by getContext: a webhook or a cron job is not the buyer's request
  385. 'request' => $aContext['buyer'],
  386. 'value' => $aRow['kind'] != self::KIND_TRIAL && $aRow['amount_default'] !== null ? (float)$aRow['amount_default'] : 0,
  387. 'props' => [
  388. 'provider' => $aRow['provider'],
  389. ],
  390. ]);
  391. return $iAdded;
  392. }
  393. /**
  394. * An order was refunded. The payment module has deleted its transactions by now, so the
  395. * ledger negates its own rows: every sale of a single order, and only the latest charge of
  396. * a recurring one (the provider refunds one charge). A refund keeps the platform and the
  397. * sources of the sale it takes back, so net revenue per source stays right. A transaction
  398. * its seller already cancelled (a reversal) is not taken back again, a trial's registration
  399. * took no money and is never taken back (a refund during the trial adds nothing), and an
  400. * order is refunded once: the payment module deletes it.
  401. *
  402. * @return int movements added
  403. */
  404. public function refundPayment($sPayment, $aPending)
  405. {
  406. $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
  407. if(!$iPendingId)
  408. return 0;
  409. $aNegated = [];
  410. foreach($this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_REFUND, self::KIND_REVERSAL]) as $aRow) {
  411. if($aRow['kind'] == self::KIND_REFUND)
  412. return 0;
  413. $aNegated[(int)$aRow['transaction_id']] = true;
  414. }
  415. $aRows = [];
  416. foreach($this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_SALE, self::KIND_RENEWAL]) as $aRow)
  417. if(!isset($aNegated[(int)$aRow['transaction_id']]))
  418. $aRows[] = $aRow;
  419. if(empty($aRows))
  420. return 0;
  421. // the latest charge: every item of it
  422. if(isset($aPending['type']) && $aPending['type'] == self::TYPE_RECURRING) {
  423. $iLast = max(array_map('intval', array_column($aRows, 'ts')));
  424. $aRows = array_filter($aRows, function($aRow) use ($iLast) { return (int)$aRow['ts'] == $iLast; });
  425. }
  426. $iAdded = 0;
  427. foreach($aRows as $aRow)
  428. $iAdded += $this->_insertNegation($aRow, self::KIND_REFUND);
  429. return $iAdded;
  430. }
  431. /**
  432. * A processed order (one transaction) was cancelled by its seller: the money goes back.
  433. */
  434. public function reverseTransaction($sPayment, $aOrder)
  435. {
  436. $iTransactionId = isset($aOrder['id']) ? (int)$aOrder['id'] : 0;
  437. if(!$iTransactionId)
  438. return 0;
  439. $aRow = $this->_oDb->getRevenueRow($sPayment, $iTransactionId, [self::KIND_SALE, self::KIND_RENEWAL]);
  440. if(empty($aRow) || !empty($this->_oDb->getRevenueRow($sPayment, $iTransactionId, [self::KIND_REFUND, self::KIND_REVERSAL])))
  441. return 0;
  442. return $this->_insertNegation($aRow, self::KIND_REVERSAL);
  443. }
  444. /**
  445. * A subscription was registered: its plan and price per month.
  446. */
  447. public function createSubscription($sPayment, $aPending, $aSubscription)
  448. {
  449. $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
  450. if(!$iPendingId)
  451. return false;
  452. $aContext = $this->getContext(isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0, isset($aPending['provider']) ? $aPending['provider'] : '', self::KIND_SALE, $this->_iNow);
  453. $aRow = $this->_getSubscriptionRow($sPayment, $aPending, $aSubscription);
  454. $aRow['started'] = $this->_iNow;
  455. $aRow['platform'] = $aContext['platform'];
  456. return $this->_oDb->saveSubscriptionRow($aRow);
  457. }
  458. public function setSubscriptionStatus($sPayment, $aPending, $sStatus)
  459. {
  460. $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
  461. if(!$iPendingId || !$this->_ensureSubscription($sPayment, $aPending))
  462. return false;
  463. $aRow = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
  464. if(!empty($aRow['canceled']))
  465. return false;
  466. return $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, ['status' => $sStatus]) > 0;
  467. }
  468. /**
  469. * A subscription was cancelled, with the reason the member or the provider gave.
  470. */
  471. public function cancelSubscription($sPayment, $aPending, $aSubscription, $sReason = '')
  472. {
  473. $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
  474. if(!$iPendingId)
  475. return false;
  476. if(empty($this->_oDb->getSubscriptionRow($sPayment, $iPendingId))) {
  477. $aRow = $this->_getSubscriptionRow($sPayment, $aPending, $aSubscription);
  478. $aRow['started'] = !empty($aSubscription['date_add']) ? (int)$aSubscription['date_add'] : $this->_iNow;
  479. $this->_oDb->saveSubscriptionRow($aRow);
  480. }
  481. return $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, [
  482. 'status' => self::STATUS_CANCELED,
  483. 'canceled' => $this->_iNow,
  484. 'reason' => get_mb_substr(trim($sReason), 0, 255),
  485. ]) > 0;
  486. }
  487. /**
  488. * The buyer finished the checkout in their own request: the order's rows a webhook
  489. * registered first get the buyer's platform, visit and sources.
  490. */
  491. public function finalizeCheckout($sPayment, $aPending)
  492. {
  493. $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
  494. if(!$iPendingId)
  495. return 0;
  496. $iClientId = isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0;
  497. $aContext = $this->getContext($iClientId, '', self::KIND_SALE, $this->_iNow);
  498. if(!$aContext['buyer'])
  499. return 0;
  500. $aSet = ['platform' => $aContext['platform']];
  501. if($aContext['link'])
  502. $aSet = array_merge($aSet, ['session_id' => $aContext['session_id']], $this->_getSourceColumns($aContext));
  503. $iUpdated = $this->_oDb->updateRevenueContext($sPayment, $iPendingId, $aSet);
  504. $aSubscription = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
  505. if(!empty($aSubscription) && in_array($aSubscription['platform'], ['', BX_ANALYTICS_PLATFORM_CORE], true))
  506. $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, ['platform' => $aContext['platform']]);
  507. return $iUpdated;
  508. }
  509. /*
  510. * Context and conversion
  511. */
  512. /**
  513. * Who made a movement and how: the buyer's own request has its platform (and, when it can
  514. * be tracked, the running visit); anything else (a webhook, a cron job, a seller or an
  515. * administrator acting on the order) is the platform `core`. An Apple in-app sale outside
  516. * the buyer's request still came from the iOS app, and so does a sale whose checkout
  517. * platform is known (`$sCheckoutPlatform`, the subscription's, when its first charge
  518. * comes by webhook). Traffic sources come from the buyer's first visit and the latest one
  519. * before the movement.
  520. *
  521. * @return array platform, buyer (it is the buyer's request), track (the action may be
  522. * recorded), link (the member may be linked to visits), session_id, first and last
  523. * (channel, referrer_host, utm_campaign)
  524. */
  525. public function getContext($iClientId, $sProvider, $sKind, $iTs, $sCheckoutPlatform = '')
  526. {
  527. $iClientId = (int)$iClientId;
  528. $bApiKey = $this->_isApiKey();
  529. $sPlatform = $this->_oRequest->getPlatform($this->_isApi(), $bApiKey);
  530. $bBuyer = $sPlatform != BX_ANALYTICS_PLATFORM_CORE && $iClientId && $this->_getLoggedProfileId() == $iClientId;
  531. if(!$bBuyer) {
  532. if($sKind == self::KIND_SALE && $sCheckoutPlatform !== '' && $sCheckoutPlatform != BX_ANALYTICS_PLATFORM_CORE)
  533. $sPlatform = $sCheckoutPlatform;
  534. else
  535. $sPlatform = $sProvider == self::PROVIDER_APPLE && $sKind == self::KIND_SALE ? BX_ANALYTICS_PLATFORM_RN_IOS : BX_ANALYTICS_PLATFORM_CORE;
  536. }
  537. $bTrack = $this->_getCollector()->isTracked($sPlatform, $iClientId, $bApiKey, $bBuyer);
  538. $bLink = $bTrack && $iClientId && $this->_oConfig->isLinkMembers();
  539. $aContext = [
  540. 'platform' => $sPlatform,
  541. 'buyer' => $bBuyer,
  542. 'track' => $bTrack,
  543. 'link' => $bLink,
  544. 'session_id' => 0,
  545. 'first' => [],
  546. 'last' => [],
  547. ];
  548. if(!$bLink)
  549. return $aContext;
  550. if($bBuyer) {
  551. $aSession = $this->_getCollector()->getRequestSession($sPlatform, $iClientId, $bApiKey);
  552. if(!empty($aSession) && (int)$aSession['profile_id'] == $iClientId)
  553. $aContext['session_id'] = (int)$aSession['id'];
  554. }
  555. return array_merge($aContext, $this->_getSources($iClientId, $iTs));
  556. }
  557. /**
  558. * The rate of an order's currency against the default one: the order's own snapshot, or
  559. * today's rate when the snapshot has none, or null when no rate is known.
  560. */
  561. public function getRate($sCurrency, $sData)
  562. {
  563. $sDefault = $this->getDefaultCurrency();
  564. $mixedRate = self::getSnapshotRate((string)$sCurrency, $sData, $sDefault);
  565. if($mixedRate !== null)
  566. return $mixedRate;
  567. if($sDefault === '' || $sCurrency === '')
  568. return null;
  569. $mixedConverted = BxDolPayments::getInstance()->convert(1, $sDefault, $sCurrency);
  570. return is_numeric($mixedConverted) && (float)$mixedConverted > 0 ? (float)$mixedConverted : null;
  571. }
  572. public function getDefaultCurrency()
  573. {
  574. if($this->_sDefaultCurrency === null) {
  575. $oPayments = BxDolPayments::getInstance();
  576. $this->_sDefaultCurrency = $oPayments->isActive() ? (string)$oPayments->getCurrencyCode() : '';
  577. }
  578. return $this->_sDefaultCurrency;
  579. }
  580. /*
  581. * Import of past payments
  582. */
  583. /**
  584. * Read past transactions, then subscriptions, from the active payment module a page at a
  585. * time until the deadline. The cursors are saved after every page, so the next step (the
  586. * hourly cron, or the Studio button) continues where this one stopped; rows the ledger has
  587. * already are skipped. Refunded orders can't be imported: the payment module deleted them.
  588. *
  589. * @param int $iDeadline unix time to stop by
  590. * @return array the import status, see getBackfillStatus()
  591. */
  592. public function backfill($iDeadline)
  593. {
  594. $CNF = &$this->_oConfig->CNF;
  595. $sPayment = $this->_getActivePayment();
  596. if($sPayment === '' || !bx_is_srv($sPayment, 'get_transactions_after', 'Orders'))
  597. return $this->getBackfillStatus();
  598. while(time() < $iDeadline) {
  599. $sCursor = (string)getParam($CNF['OPTION_BACKFILL']);
  600. if($sCursor !== '' && (int)$sCursor >= 0) {
  601. $aRows = bx_srv_ic($sPayment, 'get_transactions_after', [(int)$sCursor, $CNF['BACKFILL_PAGE']], 'Orders');
  602. if(empty($aRows) || !is_array($aRows)) {
  603. setParam($CNF['OPTION_BACKFILL'], '-1');
  604. if((string)getParam($CNF['OPTION_BACKFILL_SBS']) === '')
  605. setParam($CNF['OPTION_BACKFILL_SBS'], 'active:0');
  606. continue;
  607. }
  608. $this->importTransactions($sPayment, $aRows);
  609. $aLast = end($aRows);
  610. setParam($CNF['OPTION_BACKFILL'], (string)(int)$aLast['id']);
  611. continue;
  612. }
  613. $sCursor = (string)getParam($CNF['OPTION_BACKFILL_SBS']);
  614. if(!preg_match('/^(active|deleted):(\d+)$/', $sCursor, $aMatch) || !bx_is_srv($sPayment, 'get_subscriptions_after', 'Subscriptions'))
  615. break;
  616. $bDeleted = $aMatch[1] == 'deleted';
  617. $aRows = bx_srv_ic($sPayment, 'get_subscriptions_after', [(int)$aMatch[2], $CNF['BACKFILL_PAGE'], $bDeleted], 'Subscriptions');
  618. if(empty($aRows) || !is_array($aRows)) {
  619. setParam($CNF['OPTION_BACKFILL_SBS'], $bDeleted ? '-1' : 'deleted:0');
  620. continue;
  621. }
  622. $this->importSubscriptions($sPayment, $aRows, $bDeleted);
  623. $aLast = end($aRows);
  624. setParam($CNF['OPTION_BACKFILL_SBS'], $aMatch[1] . ':' . (int)$aLast['pending_id']);
  625. }
  626. return $this->getBackfillStatus();
  627. }
  628. /**
  629. * Start the import over: every past payment is read again, and the ones the ledger has
  630. * are skipped.
  631. */
  632. public function restartBackfill()
  633. {
  634. setParam($this->_oConfig->CNF['OPTION_BACKFILL'], '0');
  635. setParam($this->_oConfig->CNF['OPTION_BACKFILL_SBS'], '');
  636. }
  637. /**
  638. * @param bool $bCount count the rows the import wrote (it reads the whole ledger)
  639. * @return array state (unavailable: no payment module; running; done), imported (rows
  640. * the import wrote, 0 unless counted), cursor (the last transaction read)
  641. */
  642. public function getBackfillStatus($bCount = false)
  643. {
  644. $CNF = &$this->_oConfig->CNF;
  645. $sPayment = $this->_getActivePayment();
  646. if($sPayment === '' || !bx_is_srv($sPayment, 'get_transactions_after', 'Orders'))
  647. return ['state' => 'unavailable', 'imported' => 0, 'cursor' => 0];
  648. $sCursor = (string)getParam($CNF['OPTION_BACKFILL']);
  649. $sSbs = (string)getParam($CNF['OPTION_BACKFILL_SBS']);
  650. $bDone = ($sCursor === '' || (int)$sCursor < 0) && !preg_match('/^(active|deleted):\d+$/', $sSbs);
  651. return [
  652. 'state' => $bDone ? 'done' : 'running',
  653. 'imported' => $bCount ? $this->_oDb->getRevenueImportedCount($sPayment) : 0,
  654. 'cursor' => max(0, (int)$sCursor),
  655. ];
  656. }
  657. /**
  658. * Past transactions into the ledger, with the time they were registered and no platform.
  659. * A transaction the ledger has as a sale, a renewal or a trial is skipped whatever its kind,
  660. * and kinds follow the same rule as live payments (see registerPayment()) with the
  661. * subscriptions the ledger has. The import reads transactions before subscriptions, so the
  662. * trial of a subscription the ledger doesn't have yet is applied when importSubscriptions()
  663. * reads it.
  664. *
  665. * @return int movements added
  666. */
  667. public function importTransactions($sPayment, $aRows)
  668. {
  669. $aTypes = [];
  670. foreach($aRows as $aRow)
  671. $aTypes[(int)$aRow['pending_id']] = !empty($aRow['type']) ? $aRow['type'] : self::TYPE_SINGLE;
  672. // trials of the recurring orders on this page: [pending id => started, trial]
  673. $aTrials = $this->_oDb->getSubscriptionTrials($sPayment, array_keys(array_filter($aTypes, function($sType) { return $sType == self::TYPE_RECURRING; })));
  674. $aFirst = [];
  675. foreach($aRows as $aRow) {
  676. $iPendingId = (int)$aRow['pending_id'];
  677. $aSubscription = isset($aTrials[$iPendingId]) ? $aTrials[$iPendingId] : [];
  678. if(self::getChargeKind($aTypes[$iPendingId], $aSubscription, (int)$aRow['date'], (int)$aRow['date']) != self::KIND_TRIAL)
  679. $aFirst[$iPendingId] = isset($aFirst[$iPendingId]) ? min($aFirst[$iPendingId], (int)$aRow['date']) : (int)$aRow['date'];
  680. }
  681. // a renewal on this page may belong to an order whose sale the ledger already has
  682. foreach($this->_oDb->getRevenueFirstCharges($sPayment, array_keys($aTypes)) as $iPendingId => $iTs)
  683. $aFirst[(int)$iPendingId] = isset($aFirst[(int)$iPendingId]) ? min($aFirst[(int)$iPendingId], (int)$iTs) : (int)$iTs;
  684. $aKnown = array_flip($this->_oDb->getRevenueCharged($sPayment, array_column($aRows, 'id')));
  685. $iAdded = 0;
  686. foreach($aRows as $aRow) {
  687. if(isset($aKnown[(int)$aRow['id']]))
  688. continue;
  689. $aPending = [
  690. 'id' => (int)$aRow['pending_id'],
  691. 'client_id' => (int)$aRow['client_id'],
  692. 'type' => !empty($aRow['type']) ? $aRow['type'] : self::TYPE_SINGLE,
  693. 'provider' => isset($aRow['provider']) ? (string)$aRow['provider'] : '',
  694. 'order' => isset($aRow['order']) ? (string)$aRow['order'] : '',
  695. 'currency' => !empty($aRow['pending_currency']) ? $aRow['pending_currency'] : (isset($aRow['currency']) ? $aRow['currency'] : ''),
  696. 'data' => isset($aRow['data']) ? $aRow['data'] : '',
  697. ];
  698. $iTs = (int)$aRow['date'];
  699. $sKind = self::getChargeKind($aPending['type'], isset($aTrials[$aPending['id']]) ? $aTrials[$aPending['id']] : [], $iTs, isset($aFirst[$aPending['id']]) ? $aFirst[$aPending['id']] : 0);
  700. $fRate = $this->getRate($aPending['currency'], $aPending['data']);
  701. $iAdded += $this->_oDb->insertRevenue($this->_getLedgerRow($sPayment, $aPending, $aRow, $sKind, $iTs, $fRate, $this->_getImportContext((int)$aRow['client_id'], $iTs)));
  702. }
  703. return $iAdded;
  704. }
  705. /**
  706. * Past subscriptions, current or cancelled, with their payments taken from the ledger. The
  707. * charges of a subscription with a trial follow its trial: the import read them before it
  708. * knew it (see importTransactions()).
  709. */
  710. public function importSubscriptions($sPayment, $aRows, $bDeleted)
  711. {
  712. $iImported = 0;
  713. foreach($aRows as $aRow) {
  714. if(empty($aRow['pending_id']) || (isset($aRow['type']) && $aRow['type'] != self::TYPE_RECURRING))
  715. continue;
  716. $aPending = [
  717. 'id' => (int)$aRow['pending_id'],
  718. 'client_id' => (int)$aRow['client_id'],
  719. 'seller_id' => (int)$aRow['seller_id'],
  720. 'provider' => (string)$aRow['provider'],
  721. 'items' => (string)$aRow['items'],
  722. 'amount' => (float)$aRow['amount'],
  723. 'currency' => (string)$aRow['currency'],
  724. 'data' => (string)$aRow['data'],
  725. ];
  726. $aSubscription = $this->_getSubscriptionRow($sPayment, $aPending, $aRow);
  727. $aTrial = ['trial' => $aSubscription['trial'], 'started' => (int)$aRow['date_add']];
  728. if($aTrial['trial'] > 0)
  729. foreach(self::getKindChanges(self::TYPE_RECURRING, $aTrial, $this->_oDb->getRevenueRows($sPayment, $aPending['id'])) as $iId => $sKind)
  730. $this->_oDb->updateRevenueKind($iId, $sKind);
  731. $aPaid = $this->_oDb->getRevenuePaid($sPayment, $aPending['id'], self::getTrialPaidFrom($aTrial));
  732. $iImported += (int)$this->_oDb->importSubscriptionRow(array_merge($aSubscription, [
  733. 'status' => $bDeleted ? self::STATUS_CANCELED : $aSubscription['status'],
  734. 'started' => (int)$aRow['date_add'],
  735. 'first_paid' => (int)$aPaid['first'],
  736. 'last_paid' => (int)$aPaid['last'],
  737. 'paid_count' => (int)$aPaid['count'],
  738. 'canceled' => $bDeleted ? max(1, (int)$aRow['deleted']) : 0,
  739. 'reason' => $bDeleted ? get_mb_substr((string)$aRow['reason'], 0, 255) : '',
  740. 'platform' => '',
  741. ]));
  742. }
  743. return $iImported;
  744. }
  745. /*
  746. * Internals
  747. */
  748. /**
  749. * A ledger row for a transaction of an order.
  750. */
  751. protected function _getLedgerRow($sPayment, $aPending, $aTransaction, $sKind, $iTs, $fRate, $aContext)
  752. {
  753. $iModuleId = (int)$aTransaction['module_id'];
  754. $sProvider = isset($aPending['provider']) ? (string)$aPending['provider'] : (isset($aTransaction['provider']) ? (string)$aTransaction['provider'] : '');
  755. $sCurrency = !empty($aTransaction['currency']) ? (string)$aTransaction['currency'] : (!empty($aPending['currency']) ? (string)$aPending['currency'] : $this->getDefaultCurrency());
  756. $fAmount = round((float)$aTransaction['amount'], 4);
  757. return array_merge([
  758. 'ts' => (int)$iTs,
  759. 'kind' => $sKind,
  760. 'cash' => $sProvider == self::PROVIDER_CREDITS ? 0 : 1,
  761. 'payment' => $sPayment,
  762. 'pending_id' => !empty($aTransaction['pending_id']) ? (int)$aTransaction['pending_id'] : (int)$aPending['id'],
  763. 'transaction_id' => (int)$aTransaction['id'],
  764. 'order' => get_mb_substr(isset($aPending['order']) ? (string)$aPending['order'] : (isset($aTransaction['order']) ? (string)$aTransaction['order'] : ''), 0, 32),
  765. 'module_id' => $iModuleId,
  766. 'module' => $this->_getModuleName($iModuleId),
  767. 'item_id' => max(0, (int)$aTransaction['item_id']),
  768. 'item_count' => max(0, (int)$aTransaction['item_count']),
  769. 'seller_id' => max(0, (int)$aTransaction['seller_id']),
  770. 'author_id' => max(0, (int)$aTransaction['author_id']),
  771. 'client_id' => max(0, !empty($aTransaction['client_id']) ? (int)$aTransaction['client_id'] : (isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0)),
  772. 'provider' => get_mb_substr($sProvider, 0, 32),
  773. 'type' => isset($aPending['type']) ? (string)$aPending['type'] : self::TYPE_SINGLE,
  774. 'amount' => $fAmount,
  775. 'currency' => get_mb_substr($sCurrency, 0, 4),
  776. // 0: no rate known, the amount isn't converted
  777. 'rate' => $fRate !== null ? $fRate : 0,
  778. 'amount_default' => self::toDefault($fAmount, $fRate),
  779. 'platform' => $aContext['platform'],
  780. 'session_id' => (int)$aContext['session_id'],
  781. ], $this->_getSourceColumns($aContext));
  782. }
  783. /**
  784. * A refund or reversal of a ledger row: the same order, item and sources, negative amounts.
  785. */
  786. protected function _insertNegation($aRow, $sKind)
  787. {
  788. unset($aRow['id']);
  789. return $this->_oDb->insertRevenue(array_merge($aRow, [
  790. 'ts' => $this->_iNow,
  791. 'kind' => $sKind,
  792. 'amount' => -abs((float)$aRow['amount']),
  793. 'amount_default' => $aRow['amount_default'] !== null ? -abs((float)$aRow['amount_default']) : null,
  794. 'session_id' => 0,
  795. ]));
  796. }
  797. /**
  798. * A subscription row from an order and the payment module's subscription.
  799. */
  800. protected function _getSubscriptionRow($sPayment, $aPending, $aSubscription)
  801. {
  802. list($iModuleId, $iItemId) = self::getFirstItem(isset($aPending['items']) ? $aPending['items'] : '');
  803. $iPeriod = max(1, isset($aSubscription['period']) ? (int)$aSubscription['period'] : 1);
  804. $sUnit = isset($aSubscription['period_unit']) ? (string)$aSubscription['period_unit'] : '';
  805. $fAmount = self::toDefault(isset($aPending['amount']) ? (float)$aPending['amount'] : 0, $this->getRate(isset($aPending['currency']) ? (string)$aPending['currency'] : '', isset($aPending['data']) ? $aPending['data'] : ''));
  806. $sStatus = isset($aSubscription['status']) ? (string)$aSubscription['status'] : '';
  807. if(!in_array($sStatus, [self::STATUS_TRIAL, self::STATUS_ACTIVE, self::STATUS_UNPAID, self::STATUS_CANCELED], true))
  808. $sStatus = self::STATUS_UNPAID;
  809. return [
  810. 'payment' => $sPayment,
  811. 'pending_id' => (int)$aPending['id'],
  812. 'subscription_id' => get_mb_substr(isset($aSubscription['subscription_id']) ? (string)$aSubscription['subscription_id'] : '', 0, 32),
  813. 'client_id' => isset($aPending['client_id']) ? max(0, (int)$aPending['client_id']) : 0,
  814. 'seller_id' => isset($aPending['seller_id']) ? max(0, (int)$aPending['seller_id']) : 0,
  815. 'module_id' => $iModuleId,
  816. 'module' => $this->_getModuleName($iModuleId),
  817. 'item_id' => $iItemId,
  818. 'provider' => get_mb_substr(isset($aPending['provider']) ? (string)$aPending['provider'] : '', 0, 32),
  819. 'period' => $iPeriod,
  820. 'period_unit' => get_mb_substr($sUnit, 0, 16),
  821. 'trial' => isset($aSubscription['trial']) ? max(0, (int)$aSubscription['trial']) : 0,
  822. 'amount_default' => $fAmount,
  823. 'mrr_default' => self::getMrr($fAmount, $iPeriod, $sUnit),
  824. 'status' => $sStatus,
  825. ];
  826. }
  827. /**
  828. * The ledger's row for a subscription, created from the payment module's when the
  829. * subscription began before analytics was installed.
  830. */
  831. protected function _ensureSubscription($sPayment, $aPending)
  832. {
  833. $iPendingId = (int)$aPending['id'];
  834. if(!empty($this->_oDb->getSubscriptionRow($sPayment, $iPendingId)))
  835. return true;
  836. $aSubscription = [];
  837. if(bx_is_srv($sPayment, 'get_subscriptions_info', 'Subscriptions')) {
  838. $aList = bx_srv_ic($sPayment, 'get_subscriptions_info', [['pending_id' => $iPendingId]], 'Subscriptions');
  839. if(!empty($aList) && is_array($aList))
  840. $aSubscription = reset($aList);
  841. }
  842. if(empty($aPending['items']) || !isset($aPending['amount'])) {
  843. $aFresh = $this->_getPendingOrder($sPayment, $iPendingId);
  844. if(!empty($aFresh))
  845. $aPending = $aFresh;
  846. }
  847. $aRow = $this->_getSubscriptionRow($sPayment, $aPending, is_array($aSubscription) ? $aSubscription : []);
  848. $aRow['started'] = !empty($aSubscription['date_add']) ? (int)$aSubscription['date_add'] : $this->_iNow;
  849. return $this->_oDb->saveSubscriptionRow($aRow);
  850. }
  851. /**
  852. * Context of a movement read from history: no platform and no visit, sources when the
  853. * member's visits may be linked.
  854. */
  855. protected function _getImportContext($iClientId, $iTs)
  856. {
  857. $aContext = ['platform' => '', 'buyer' => false, 'track' => false, 'link' => false, 'session_id' => 0, 'first' => [], 'last' => []];
  858. if(!$iClientId || !$this->_oConfig->isLinkMembers() || !$this->_getCollector()->isTracked(BX_ANALYTICS_PLATFORM_CORE, $iClientId, false, false))
  859. return $aContext;
  860. return array_merge($aContext, $this->_getSources($iClientId, $iTs));
  861. }
  862. protected function _getSources($iClientId, $iTs)
  863. {
  864. $sKey = $iClientId . ':' . $iTs;
  865. if(!isset($this->_aSources[$sKey]))
  866. $this->_aSources[$sKey] = $this->_oDb->getSessionSources($iClientId, $iTs);
  867. return $this->_aSources[$sKey];
  868. }
  869. protected function _getSourceColumns($aContext)
  870. {
  871. $aColumns = [];
  872. foreach(['first', 'last'] as $sWhich) {
  873. $aSource = !empty($aContext[$sWhich]) && is_array($aContext[$sWhich]) ? $aContext[$sWhich] : [];
  874. $aColumns[$sWhich . '_channel'] = isset($aSource['channel']) ? (string)$aSource['channel'] : '';
  875. $aColumns[$sWhich . '_referrer_host'] = isset($aSource['referrer_host']) ? (string)$aSource['referrer_host'] : '';
  876. $aColumns[$sWhich . '_utm_campaign'] = isset($aSource['utm_campaign']) ? (string)$aSource['utm_campaign'] : '';
  877. }
  878. return $aColumns;
  879. }
  880. protected function _getModuleName($iModuleId)
  881. {
  882. $iModuleId = (int)$iModuleId;
  883. if(!$iModuleId)
  884. return '';
  885. if(!isset($this->_aModuleNames[$iModuleId])) {
  886. $aModule = BxDolModuleQuery::getInstance()->getModuleById($iModuleId);
  887. $this->_aModuleNames[$iModuleId] = !empty($aModule['name']) ? get_mb_substr($aModule['name'], 0, 32) : '';
  888. }
  889. return $this->_aModuleNames[$iModuleId];
  890. }
  891. /**
  892. * Transactions of an order from the payment module that reported it.
  893. */
  894. protected function _getTransactions($sPayment, $iPendingId)
  895. {
  896. if(!bx_is_srv($sPayment, 'get_orders_info', 'Orders'))
  897. return [];
  898. $aRows = bx_srv_ic($sPayment, 'get_orders_info', [['pending_id' => (int)$iPendingId]], 'Orders');
  899. return is_array($aRows) ? $aRows : [];
  900. }
  901. protected function _getPendingOrder($sPayment, $iPendingId)
  902. {
  903. if(!bx_is_srv($sPayment, 'get_pending_orders_info', 'Orders'))
  904. return [];
  905. $aRows = bx_srv_ic($sPayment, 'get_pending_orders_info', [['id' => (int)$iPendingId]], 'Orders');
  906. return !empty($aRows) && is_array($aRows) ? reset($aRows) : [];
  907. }
  908. protected function _getActivePayment()
  909. {
  910. $oPayments = BxDolPayments::getInstance();
  911. return $oPayments->isActive() ? (string)$oPayments->getActive() : '';
  912. }
  913. protected function _getCollector()
  914. {
  915. if($this->_oCollector === null)
  916. $this->_oCollector = new BxAnalyticsCollector($this->_oModule, $this->_oRequest, $this->_iNow);
  917. return $this->_oCollector;
  918. }
  919. protected function _getLoggedProfileId()
  920. {
  921. return array_key_exists('logged', $this->_aOptions) ? (int)$this->_aOptions['logged'] : (int)bx_get_logged_profile_id();
  922. }
  923. protected function _isApi()
  924. {
  925. return array_key_exists('api', $this->_aOptions) ? (bool)$this->_aOptions['api'] : defined('BX_API');
  926. }
  927. protected function _isApiKey()
  928. {
  929. return array_key_exists('api_key', $this->_aOptions) ? (bool)$this->_aOptions['api_key'] : $this->_oModule->isApiKeyRequest();
  930. }
  931. }
  932. /** @} */