diff --git a/assets/css/about.css b/assets/css/about.css new file mode 100644 index 0000000..8d2eab9 --- /dev/null +++ b/assets/css/about.css @@ -0,0 +1,169 @@ +/*------------------------------------------------------------------------------ + Subscriptions 2.0 About Page CSS +------------------------------------------------------------------------------*/ +.wcs-badge:before { + font-family: WooCommerce !important; + content: "\e03d"; + color: #fff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 80px; + font-weight: normal; + width: 165px; + height: 165px; + line-height: 165px; + text-align: center; + position: absolute; + top: 0; + left: 0; + margin: 0; + vertical-align: middle; +} +.wcs-badge { + position: relative;; + background: #9c5d90; + text-rendering: optimizeLegibility; + padding-top: 150px; + height: 52px; + width: 165px; + font-weight: 600; + font-size: 14px; + text-align: center; + color: #ddc8d9; + margin: 5px 0 0 0; + -webkit-box-shadow: 0 1px 3px rgba(0,0,0,.2); + box-shadow: 0 1px 3px rgba(0,0,0,.2); +} +.about-wrap .feature-section { + padding-bottom: 2em; +} +.about-wrap .wcs-badge { + position: absolute; + top: 0; + right: 0; +} +.about-wrap .wcs-feature { + overflow: visible !important; + *zoom:1; +} +.about-wrap .wcs-feature:before, +.about-wrap .wcs-feature:after { + content: " "; + display: table; +} +.about-wrap .wcs-feature:after { + clear: both; +} +.about-wrap .two-col .feature-right { + float: right; +} +.about-wrap .two-col .feature-copy, +.about-wrap .two-col .feature-image { + margin-top: 2em; +} +.about-wrap div.icon { + width: 0 !important; + padding: 0; + margin: 0; +} +.woocommerce-message { + position: relative; + border-left-color: #cc99c2!important; + overflow: hidden; +} + +.woocommerce-message a.button-primary,p.woocommerce-actions a.button-primary, +.woocommerce-message a.button-primary:focus, p.woocommerce-actions a.button-primary:focus, +.woocommerce-message a.button-primary:active, p.woocommerce-actions a.button-primary:active { + background: #cc99c2; + border-color: #b366a4; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 rgba(0,0,0,.15); + box-shadow: inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 rgba(0,0,0,.15); + color: #fff; + text-decoration: none; +} + +.woocommerce-message a.button-primary:hover,p.woocommerce-actions a.button-primary:hover { + background: #bb77ae; + border-color: #aa559a; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 rgba(0,0,0,.15); + box-shadow: inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 rgba(0,0,0,.15); +} + +.woocommerce-message a.button-primary:active,p.woocommerce-actions a.button-primary:active { + background: #aa559a; + border-color: #aa559a; +} + +.woocommerce-message a.docs,.woocommerce-message a.skip,p.woocommerce-actions a.docs,p.woocommerce-actions a.skip { + opacity: .7; +} + +.woocommerce-message .twitter-share-button,p.woocommerce-actions .twitter-share-button { + vertical-align: middle; + margin-left: 3px; +} + +p.woocommerce-actions { + margin-bottom: 2em; +} + +.woocommerce-about-text { + margin-bottom: 1em!important; +} + +.about-wrap .feature-section.three-col .col { + width: 29.95%; + margin-right: 4.999999999%; + float: left; +} +.about-wrap .feature-section.two-col .col { + float: left; +} +.about-wrap .feature-section .col.last-feature { + margin-right: 0; +} +.about-wrap .feature-section .col.feature-right { + margin-right: 0; + float: right !important; +} + +@media only screen and (max-width: 1200px) { + .about-wrap .two-col .feature-copy { + margin-top: 0; + } +} + +@media only screen and (max-width: 781px) { + .about-wrap .two-col .feature-copy, + .about-wrap .feature-section { + padding-bottom: 1em; + } + .about-wrap .two-col .feature-image, + .about-wrap .changelog { + border-bottom: 0; + margin-bottom: 0; + padding-bottom: 0; + } + .about-wrap .feature-section.three-col .col { + width: 100%; + margin: 40px 0 0; + padding: 0 0 40px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } +} + +@media only screen and (max-width: 500px) { + .about-wrap .wcs-badge:before { + width: 100%; + } + .about-wrap .wcs-badge { + position: relative; + margin-bottom: 1.5em; + width: 100%; + } + .about-wrap .three-col .col { + width: 100% !important; + float: none !important; + } +} \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..effd599 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,496 @@ +/* Plugin Activation */ +.woocommerce-subscriptions-activated p a.button-primary { + display: inline-block; +} +.woocommerce-subscriptions-activated a.button-primary:hover { + background: #bb77ae; + border-color: #aa559a; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 rgba(0,0,0,.15); + box-shadow: inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 rgba(0,0,0,.15); +} +.woocommerce-subscriptions-activated a.button-primary:active, +.woocommerce-subscriptions-activated a.button-primary:active { + background: #aa559a; + border-color: #aa559a; +} + +/* Subscriptions Admin Page */ +.post-type-shop_subscription .widefat .column-status, +.post-type-shop_subscription .widefat .column-order_id, +.post-type-shop_subscription .widefat .column-order_title { + width: 142px; + text-align: left; +} +.widefat .type-shop_subscription td.column-status { + padding: 6px 7px; +} +.widefat .type-shop_subscription .column-status mark { + display:block; + text-align: center; + white-space: nowrap; + padding: 0 2px; + background: #999; + border: 1px solid #999; + -webkit-box-shadow:inset 0 0 2px 1px rgba(255,255,255,0.5); + -webkit-border-radius:4px; + -moz-border-radius:4px; + -o-border-radius:4px; + border-radius:4px; + margin: 0; + font-size:9px; + text-transform: uppercase; + color:#fff; + font-weight: bold; + text-shadow:0 1px 0 rgba(0,0,0,0.3); +} +.widefat .type-shop_subscription .column-status mark.active { + background-color: #339933; + border-color: #339933; +} +.widefat .type-shop_subscription .column-status mark.expired { + background-color: #A0658B; + border-color: #A0658B; +} +.widefat .type-shop_subscription .column-status mark.on-hold { + background-color: #E66F00; + border-color: #E66F00; +} +.widefat .type-shop_subscription .column-status mark.failed { + background-color: red; + border-color: red; +} +.widefat .type-shop_subscription .column-status mark.cancelled { + background-color: #ccc; + border-color: #ccc; +} +.widefat .type-shop_subscription .column-status mark.processing { + background-color: #2184c2; + border-color: #2184c2; +} + +/* Product List table */ +table.wp-list-table span.product-type.variable-subscription { + background-position: -48px 0; +} + +/* Orders List Table */ +a.close-subscriptions-search { + font-size: 1em; + padding: 0em 0.45em; + border-radius: 1.4em; + background: #dd0000; + color: #ffffff; + margin: -0.3em 0.6em 0 0; +} +.dismiss-subscriptions-search { + position: relative; +} +.dismiss-subscriptions-search a { + padding-bottom: 9px; +} + +/* Edit Product/Subscriptions Page */ +.woocommerce_options_panel .subscription_pricing { + float: left; + clear: both; + height: 100%; + width: 100%; +} +.woocommerce_options_panel .subscription_pricing .description { + font-style: normal; +} +.subscription_pricing .form-field, +.subscription_pricing .form-field *, +.variable_subscription_pricing .form-field, +.variable_subscription_pricing .form-field *, +.variable_subscription_trial .form-field, +.variable_subscription_trial .form-field * { + float:left; +} +p._subscription_price_field, +p._subscription_period_field, +p._subscription_length_field { + display: inline-block; +} +.woocommerce_options_panel input[type=text].wc_input_subscription_price, +.woocommerce_options_panel input[type=text].wc_input_subscription_intial_price, +.woocommerce_options_panel input[type=text].wc_input_subscription_trial_length, +.woocommerce_options_panel input[type=text].wc_input_subscription_payment_sync { + width: 5.5em; +} +p._subscription_period_field label, +p._subscription_length_field label, +p._subscription_period_interval_field label, +p._subscription_trial_period_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_price_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_period_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_length_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_period_interval_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_length_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_period_field label, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_payment_sync_field label { + display: none; +} +#woocommerce-product-data .woocommerce_options_panel p._subscription_price_field { + padding-right: 0 !important; +} +p._subscription_price_field input, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_price_field input, +p._subscription_sign_up_fee_field input, +p._subscription_trial_length_field input, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_length_field input, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_payment_sync_field input { + width: 5em; +} +.woocommerce_options_panel #sale-price-period { + margin-right: 1em; +} +.wc_input_subscription_length { + min-width: 90px; +} +.wc-metaboxes-wrapper .wc-metabox table td .wc_input_subscription_length { + width: 90px; +} +._subscription_trial_period { + min-width: 7.1em; +} +.wc-metaboxes-wrapper .wc-metabox table td ._subscription_trial_period { + width: 7.1em; +} +#woocommerce-product-data .woocommerce_options_panel p._subscription_period_field, +#woocommerce-product-data .woocommerce_options_panel p._subscription_length_field, +#woocommerce-product-data .woocommerce_options_panel p._subscription_period_interval_field, +#woocommerce-product-data .woocommerce_options_panel p._subscription_trial_period_field, +#woocommerce-product-data .woocommerce_options_panel p._subscription_payment_sync_date_month_field { + padding: 5px 0 5px 5px !important; +} +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_price_field { + width: 70px !important; +} +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_sign_up_fee_field { + width: 100% !important; +} +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_price_field, +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_sign_up_fee_field, +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_length_field, +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_payment_sync_field { + padding: 0 0 0 0 !important; + margin: 0; +} +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_period_field select, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_length_field select { + width: auto !important; +} +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_period_field select { + width: 88px !important; +} +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_period_field img.help_tip { + margin-top: 4px; +} +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_period_field span, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_length_field span, +.wc-metaboxes-wrapper .wc-metabox table td p._subscription_sign_up_fee_field span { + padding: 5px 0 0 2px; +} +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_period_field, +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_length_field, +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_period_interval_field, +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_period_field { + padding: 0 0 0 5px !important; + margin: 0; +} + +#woocommerce-product-data .wc-metaboxes-wrapper .wc-metabox table td p._subscription_trial_period_field select { + margin-left: 5px; +} + +#woocommerce-product-data .woocommerce_options_panel p._subscription_trial_length_field, +#woocommerce-product-data .woocommerce_options_panel p._subscription_sign_up_fee_field, +#woocommerce-product-data .woocommerce_options_panel p._subscription_payment_sync_date_day_field { + clear: left; + padding-right: 0px !important; +} +.woocommerce_options_panel p._subscription_trial_length_field, +.woocommerce_options_panel p._subscription_trial_period_field, +.woocommerce_options_panel p._subscription_sign_up_fee_field { + margin-top: 0; +} +.woocommerce_options_panel p._subscription_trial_period_field img, +.woocommerce_options_panel p._subscription_sign_up_fee_field img, +.woocommerce_options_panel p._subscription_payment_sync_date_field img, +.woocommerce_options_panel p._subscription_payment_sync_date_month_field img { + padding-top: 0.7em; +} + +.subscription_sync_week_month select.wc_input_subscription_payment_sync { + min-width: 180px; +} + +.wc-metaboxes-wrapper .wc-metabox table tr.variable_subscription_sync td select.wc_input_subscription_payment_sync { + width: auto !important; +} + +._subscription_payment_sync_field img.help_tip { + padding-top: 8px; +} + +.variable_subscription_sync p._subscription_payment_sync_field { + padding-left: 0 !important; +} + +/* Variation Pricing Fields in WooCommerce 2.3 */ +.variable_subscription_pricing_2_3 .wcs_hidden_label { + display: none; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_price { + max-width: 24%; + clear: left; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_period_interval { + max-width: 41%; + float: right; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_period { + max-width: 32%; + float: right; +} +.variable_subscription_pricing_2_3.variable_subscription_trial p.form-row { + margin-bottom: 0; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_trial_period { + max-width: 47%; + float: right; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_trial_length { + max-width: 48%; +} +.variable_subscription_pricing_2_3 .variable_subscription_trial_sign_up { + clear: both; +} +.variable_subscription_pricing_2_3 p._subscription_length_field label { + display: inline-block; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_payment_sync_day { + max-width: 13%; +} +.variable_subscription_pricing_2_3 .wc_input_subscription_payment_sync_month { + max-width: 86%; + float: right; +} +@media screen and (max-width: 1190px) { + .variable_subscription_pricing_2_3 p._subscription_price_field, + .variable_subscription_pricing_2_3 p._subscription_length_field { + width: 100%; + } + .variable_subscription_pricing_2_3 p._subscription_price_field { + width: 100%; + margin-bottom: 0; + } + .variable_subscription_pricing_2_3 .wc_input_subscription_payment_sync_day { + max-width: 20%; + } + .variable_subscription_pricing_2_3 .wc_input_subscription_payment_sync_month { + max-width: 78%; + float: right; + } +} + +/* Users Administration Screen */ +.woocommerce_active_subscriber .active-subscriber:before { + content: "\f147"; + display: inline-block; + -webkit-font-smoothing: antialiased; + font: normal 24px/1 'dashicons'; + vertical-align: top; +} + +/* Add/Edit Subscription Screen */ +#woocommerce-subscription-data .handlediv, +#woocommerce-subscription-data h3.hndle { + display: none; +} +#woocommerce-subscription-data .inside { + padding: 0; + margin: 0; +} +#woocommerce-subscription-schedule strong { + display: inline-block; +} +#woocommerce-subscription-schedule .date-fields { + margin: 1em 0; +} +#woocommerce-subscription-schedule p._billing_period_field, +#woocommerce-subscription-schedule p._billing_interval_field { + display: inline-block; + padding: 0 0 0 1px; + margin: 0.5em 0 0; +} +#woocommerce-subscription-schedule p._billing_interval_field { + padding-left: 0; +} +#woocommerce-subscription-schedule p._billing_interval_field { + width: 66%; +} +#woocommerce-subscription-schedule p._billing_period_field { + width: 30%; +} +#woocommerce-subscription-schedule p._billing_interval_field label { + display: inline-block; + font-weight: bold; +} +#woocommerce-subscription-schedule p._billing_period_field label { + display: none; +} +#woocommerce-subscription-schedule p._billing_interval_field select, +#woocommerce-subscription-schedule p._billing_period_field select { + display: inline-block; +} +#woocommerce-subscription-schedule p._billing_interval_field select { + width: 58%; + margin-left: 5px; +} +#woocommerce-subscription-schedule p._billing_period_field select { + width: 100%; +} +#woocommerce-subscription-schedule .date-fields label, +#woocommerce-subscription-schedule .wcs-edit-date-action { + display: inline-block; + font-weight: bold; +} +#woocommerce-subscription-schedule .wcs-date-input input[type="text"]:first-of-type { + width: 8em; +} +#woocommerce-subscription-schedule img.ui-datepicker-trigger { + float: left; + margin: 0.5em 0.1em; +} +#woocommerce-subscription-schedule .wcs-date-input, +#woocommerce-subscription-schedule .billing-schedule-edit { + width: 100%; +} +#woocommerce-subscription-schedule .wcs-edit-date-action { + width: 1.5em; + float: right; +} +#woocommerce-subscription-schedule .wcs-edit-date, +#woocommerce-subscription-schedule .wcs-delete-date { + text-indent: -9999px; + position: relative; + height: 1em; + width: 1em; + display: inline-block; + margin: 0 .5em 0 0; +} +#woocommerce-subscription-schedule .wcs-edit-date:before, +#woocommerce-subscription-schedule .wcs-delete-date:before { + font-family: WooCommerce; + speak: none; + font-weight: 400; + font-variant: normal; + text-transform: none; + line-height: 1; + -webkit-font-smoothing: antialiased; + margin: 0; + text-indent: 0; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + color: #999; +} +#woocommerce-subscription-schedule .wcs-edit-date:before { + content: "\e603"; +} +#woocommerce-subscription-schedule .wcs-edit-date:hover:before { + color: #555555; +} +#woocommerce-subscription-schedule .wcs-delete-date:before { + content: "\e013"; +} +#woocommerce-subscription-schedule .wcs-delete-date:hover:before { + color: #AA0000; +} + +/* Related Orders Metabox on Edit Subscription/Order Admin Screen */ +#subscription_renewal_orders .inside { + margin: 0; + padding: 0; +} + +.woocommerce_subscriptions_related_orders { + margin: 0; + overflow: auto; +} + +.woocommerce_subscriptions_related_orders table { + width: 100%; + background: #fff; + border-collapse: collapse; +} + +.woocommerce_subscriptions_related_orders table thead th { + background: #f8f8f8; + padding: 8px; + font-size: 11px; + text-align: left; + color: #555; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.woocommerce_subscriptions_related_orders table thead th:last-child { + padding-right: 12px; +} + +.woocommerce_subscriptions_related_orders table thead th:first-child { + padding-left: 12px; +} + +.woocommerce_subscriptions_related_orders table thead th:last-of-type, +.woocommerce_subscriptions_related_orders table td:last-of-type { + text-align: right; +} + +.woocommerce_subscriptions_related_orders table tbody th, +.woocommerce_subscriptions_related_orders table td { + padding: 8px; + text-align: left; + line-height: 26px; + vertical-align: top; + border-bottom: 1px dotted #ececec; +} + +.woocommerce_subscriptions_related_orders table tbody th:last-child, +.woocommerce_subscriptions_related_orders table td:last-child { + padding-right: 12px; +} + +.woocommerce_subscriptions_related_orders table tbody th:first-child, +.woocommerce_subscriptions_related_orders table td:first-child { + padding-left: 12px; +} + +.woocommerce_subscriptions_related_orders table tbody tr:last-child td { + border-bottom: none; +} + +/* Hide irrelevant sections on Edit Subscription screen */ +body.post-type-shop_subscription .order_actions #actions optgroup[label='Resend order emails'], +body.post-type-shop_subscription .add-items .description.tips, +body.post-type-shop_subscription .add-items .button.refund-items { + display: none; +} +@media screen and (max-width: 782px) { + #woocommerce-subscription-schedule .wcs-date-input input[type="text"]:first-of-type { + width: 45%; + } + #woocommerce-subscription-schedule .wcs-date-input input[type="text"]:not(:first-of-type) { + width: 19%; + } +} diff --git a/assets/css/checkout.css b/assets/css/checkout.css new file mode 100644 index 0000000..ec51784 --- /dev/null +++ b/assets/css/checkout.css @@ -0,0 +1,21 @@ +/* Cart/Checkout Pages */ +.shipping.recurring-total ul { + list-style: none outside; + margin: 0; + padding: 0; +} +.shipping.recurring-total ul li { + margin: 0; + padding: .25em 0 .25em 22px; + text-indent: -22px; + list-style: none outside; +} +.shipping.recurring-total ul li input { + margin: 3px 0.5ex; +} +.shipping.recurring-total ul li label { + display: inline; +} +.shipping.recurring-total ul .amount { + font-weight: 700; +} diff --git a/assets/css/view-subscription.css b/assets/css/view-subscription.css new file mode 100644 index 0000000..1f40e8d --- /dev/null +++ b/assets/css/view-subscription.css @@ -0,0 +1,6 @@ +.subscription_details .button { + margin-bottom: 2px; + width: 100%; + max-width: 200px; + text-align: center; +} diff --git a/assets/css/wcs-upgrade.css b/assets/css/wcs-upgrade.css new file mode 100644 index 0000000..b8ad2a2 --- /dev/null +++ b/assets/css/wcs-upgrade.css @@ -0,0 +1,50 @@ +body { + margin-top: 100px; + padding-bottom: 2em; +} +.help_tip .tooltip { + display: none; +} +.help_tip:hover .tooltip { + display: block; +} +.tooltip { + position: absolute; + width: 480px; + height: 270px; + line-height: 1.5em; + padding: 10px; + font-size: 14px; + text-align: left; + color: rgb(18, 18, 18); + background: rgb(249, 249, 249); + border: 4px solid rgb(249, 249, 249); + border-radius: 5px; + text-shadow: none; + box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 4px 1px; + margin-top: -332px; + margin-left: 100px; +} +.tooltip:after { + content: ""; + position: absolute; + width: 0; + height: 0; + border-width: 14px; + border-style: solid; + border-color: #F9F9F9 transparent transparent transparent; + top: 292px; + left: 366px; +} +#update-messages, +#update-complete, +#update-error { + display: none; +} +.log-notice { + color: #a0a0a0; +} +p.log-notice { + font-size: 0.8em; + color: #a0a0a0; +} diff --git a/assets/images/add-edit-subscription-screen.png b/assets/images/add-edit-subscription-screen.png new file mode 100644 index 0000000..183d79f Binary files /dev/null and b/assets/images/add-edit-subscription-screen.png differ diff --git a/assets/images/admin-change-payment-method.jpg b/assets/images/admin-change-payment-method.jpg new file mode 100644 index 0000000..19df347 Binary files /dev/null and b/assets/images/admin-change-payment-method.jpg differ diff --git a/assets/images/ajax-loader.gif b/assets/images/ajax-loader.gif new file mode 100644 index 0000000..e01a70f Binary files /dev/null and b/assets/images/ajax-loader.gif differ diff --git a/assets/images/ajax-loader@2x.gif b/assets/images/ajax-loader@2x.gif new file mode 100644 index 0000000..261fd7e Binary files /dev/null and b/assets/images/ajax-loader@2x.gif differ diff --git a/assets/images/billing-schedules-meta-box.png b/assets/images/billing-schedules-meta-box.png new file mode 100644 index 0000000..b156012 Binary files /dev/null and b/assets/images/billing-schedules-meta-box.png differ diff --git a/assets/images/checkout-recurring-totals.png b/assets/images/checkout-recurring-totals.png new file mode 100644 index 0000000..150869b Binary files /dev/null and b/assets/images/checkout-recurring-totals.png differ diff --git a/assets/images/drip-downloadable-content.jpg b/assets/images/drip-downloadable-content.jpg new file mode 100644 index 0000000..f761dc1 Binary files /dev/null and b/assets/images/drip-downloadable-content.jpg differ diff --git a/assets/images/view-subscription.png b/assets/images/view-subscription.png new file mode 100644 index 0000000..2e9d035 Binary files /dev/null and b/assets/images/view-subscription.png differ diff --git a/assets/images/woocommerce_subscriptions_logo.png b/assets/images/woocommerce_subscriptions_logo.png new file mode 100644 index 0000000..57b3b73 Binary files /dev/null and b/assets/images/woocommerce_subscriptions_logo.png differ diff --git a/assets/js/admin/admin-pointers.js b/assets/js/admin/admin-pointers.js new file mode 100644 index 0000000..d32fd3d --- /dev/null +++ b/assets/js/admin/admin-pointers.js @@ -0,0 +1,57 @@ +jQuery(document).ready(function($){ + + if(arePointersEnabled()){ + setTimeout(showSubscriptionPointers, 800); // give TinyMCE a chance to finish loading + } + + $('select#product-type').change(function(){ + if(arePointersEnabled()){ + $('#product-type').pointer('close'); + } + }); + + $('#_subscription_price, #_subscription_period, #_subscription_length').change(function(){ + if(arePointersEnabled()){ + $('.options_group.subscription_pricing').pointer('close'); + $('#product-type').pointer('close'); + } + }); + + function arePointersEnabled(){ + if($.getParameterByName('subscription_pointers')=='true'){ + return true; + } else { + return false; + } + } + + function showSubscriptionPointers(){ + $('#product-type').pointer({ + content: WCSPointers.typePointerContent, + position: { + edge: 'left', + align: 'center' + }, + close: function() { + if ($('select#product-type').val()==WCSubscriptions.productType){ + $('.options_group.subscription_pricing:not(".subscription_sync")').pointer({ + content: WCSPointers.pricePointerContent, + position: 'bottom', + close: function() { + dismissSubscriptionPointer(); + } + }).pointer('open'); + } + dismissSubscriptionPointer(); + } + }).pointer('open'); + } + + function dismissSubscriptionPointer(){ + $.post( ajaxurl, { + pointer: 'wcs_pointer', + action: 'dismiss-wp-pointer' + }); + } + +}); \ No newline at end of file diff --git a/assets/js/admin/admin.js b/assets/js/admin/admin.js new file mode 100644 index 0000000..0dee5cc --- /dev/null +++ b/assets/js/admin/admin.js @@ -0,0 +1,490 @@ +jQuery(document).ready(function($){ + + $.extend({ + getParameterByName: function(name) { + name = name.replace(/[\[]/, '\\\[').replace(/[\]]/, '\\\]'); + var regexS = '[\\?&]' + name + '=([^&#]*)'; + var regex = new RegExp(regexS); + var results = regex.exec(window.location.search); + if(results == null) { + return ''; + } else { + return decodeURIComponent(results[1].replace(/\+/g, ' ')); + } + }, + showHideSubscriptionMeta: function(){ + if ($('select#product-type').val()==WCSubscriptions.productType) { + $('.show_if_simple').show(); + $('.grouping_options').hide(); + $('.options_group.pricing ._regular_price_field').hide(); + $('#sale-price-period').show(); + $('.hide_if_subscription').hide(); + $( 'input#_manage_stock' ).change(); + + if('day' == $('#_subscription_period').val()) { + $('.subscription_sync').hide(); + } + } else { + $('.options_group.pricing ._regular_price_field').show(); + $('#sale-price-period').hide(); + } + }, + showHideVariableSubscriptionMeta: function(){ + if ($('select#product-type').val()=='variable-subscription') { + + $( 'input#_downloadable' ).prop( 'checked', false ); + $( 'input#_virtual' ).removeAttr( 'checked' ); + + $('.show_if_variable').show(); + $('.hide_if_variable').hide(); + $('.show_if_variable-subscription').show(); + $('.hide_if_variable-subscription').hide(); + $( 'input#_manage_stock' ).change(); + + // Make the sale price row full width + $('.sale_price_dates_fields').prev('.form-row').addClass('form-row-full').removeClass('form-row-last'); + + } else { + + if ($('select#product-type').val()=='variable') { + $('.show_if_variable-subscription').hide(); + $('.show_if_variable').show(); + $('.hide_if_variable').hide(); + } + + // Restore the sale price row width to half + $('.sale_price_dates_fields').prev('.form-row').removeClass('form-row-full').addClass('form-row-last'); + } + }, + setSubscriptionLengths: function(){ + $('[name^="_subscription_length"], [name^="variable_subscription_length"]').each(function(){ + var $lengthElement = $(this), + selectedLength = $lengthElement.val(), + hasSelectedLength = false, + matches = $lengthElement.attr('name').match(/\[(.*?)\]/), + periodSelector, + interval; + + if (matches) { // Variation + periodSelector = '[name="variable_subscription_period['+matches[1]+']"]'; + billingInterval = parseInt($('[name="variable_subscription_period_interval['+matches[1]+']"]').val()); + } else { + periodSelector = '#_subscription_period'; + billingInterval = parseInt($('#_subscription_period_interval').val()); + } + + $lengthElement.empty(); + + $.each(WCSubscriptions.subscriptionLengths[ $(periodSelector).val() ], function(length,description) { + if(parseInt(length) == 0 || 0 == (parseInt(length) % billingInterval)) { + $lengthElement.append($('').attr('value',length).text(description)); + } + }); + + $lengthElement.children('option').each(function(){ + if (this.value == selectedLength) { + hasSelectedLength = true; + return false; + } + }); + + if(hasSelectedLength){ + $lengthElement.val(selectedLength); + } else { + $lengthElement.val(0); + } + + }); + }, + setTrialPeriods: function(){ + $('[name^="_subscription_trial_length"], [name^="variable_subscription_trial_length"]').each(function(){ + var $trialLengthElement = $(this), + trialLength = $trialLengthElement.val(), + matches = $trialLengthElement.attr('name').match(/\[(.*?)\]/), + periodStrings; + + if (matches) { // Variation + $trialPeriodElement = $('[name="variable_subscription_trial_period['+matches[1]+']"]'); + } else { + $trialPeriodElement = $('#_subscription_trial_period'); + } + + selectedTrialPeriod = $trialPeriodElement.val(); + + $trialPeriodElement.empty(); + + if( parseInt(trialLength) == 1 ) { + periodStrings = WCSubscriptions.trialPeriodSingular; + } else { + periodStrings = WCSubscriptions.trialPeriodPlurals; + } + + $.each(periodStrings, function(key,description) { + $trialPeriodElement.append($('').attr('value',key).text(description)); + }); + + $trialPeriodElement.val(selectedTrialPeriod); + }); + }, + setSalePeriod: function(){ + $('#sale-price-period').fadeOut(80,function(){ + $('#sale-price-period').text($('#_subscription_period_interval option:selected').text()+' '+$('#_subscription_period option:selected').text()); + $('#sale-price-period').fadeIn(180); + }); + }, + setSyncOptions: function(periodField) { + + if ( typeof periodField != 'undefined' ) { + + if ($('select#product-type').val()=='variable-subscription') { + var $container = periodField.closest('.woocommerce_variable_attributes').find('.variable_subscription_sync'); + } else { + $container = periodField.closest('#general_product_data').find('.subscription_sync') + } + + var $syncWeekMonthContainer = $container.find('.subscription_sync_week_month'), + $syncWeekMonthSelect = $syncWeekMonthContainer.find('select'), + $syncAnnualContainer = $container.find('.subscription_sync_annual'), + $varSubField = $container.find('[name^="variable_subscription_payment_sync_date"]'), + billingPeriod; + + if ($varSubField.length > 0) { // Variation + var matches = $varSubField.attr('name').match(/\[(.*?)\]/); + $subscriptionPeriodElement = $('[name="variable_subscription_period['+matches[1]+']"]'); + } else { + $subscriptionPeriodElement = $('#_subscription_period'); + } + + billingPeriod = $subscriptionPeriodElement.val(); + + if('day'==billingPeriod) { + $syncWeekMonthSelect.val(0); + $syncAnnualContainer.find('input[type="number"]').val(0); + } else { + if('year'==billingPeriod) { + // Make sure the year sync fields are reset + $syncAnnualContainer.find('input[type="number"]').val(0); + // And the week/month field has no option selected + $syncWeekMonthSelect.val(0); + } else { + // Make sure the year sync value is 0 + $syncAnnualContainer.find('input[type="number"]').val(0); + // And the week/month field has the appropriate options + $syncWeekMonthSelect.empty(); + $.each(WCSubscriptions.syncOptions[billingPeriod], function(key,description) { + $syncWeekMonthSelect.append($('').attr('value',key).text(description)); + }); + } + } + } + }, + showHideSyncOptions: function(){ + if($('#_subscription_payment_sync_date').length > 0 || $('.wc_input_subscription_payment_sync').length > 0){ + $('.subscription_sync, .variable_subscription_sync').each(function(){ // loop through all sync field groups + var $syncWeekMonthContainer = $(this).find('.subscription_sync_week_month'), + $syncWeekMonthSelect = $syncWeekMonthContainer.find('select'), + $syncAnnualContainer = $(this).find('.subscription_sync_annual'), + $varSubField = $(this).find('[name^="variable_subscription_payment_sync_date"]'), + $slideSwitch = false, // stop the general sync field group sliding down if editing a variable subscription + billingPeriod; + + if ($varSubField.length > 0) { // Variation + var matches = $varSubField.attr('name').match(/\[(.*?)\]/); + $subscriptionPeriodElement = $('[name="variable_subscription_period['+matches[1]+']"]'); + if ($('select#product-type').val()=='variable-subscription') { + $slideSwitch = true; + } + } else { + $subscriptionPeriodElement = $('#_subscription_period'); + if ($('select#product-type').val()==WCSubscriptions.productType) { + $slideSwitch = true; + } + } + + billingPeriod = $subscriptionPeriodElement.val(); + + if('day'==billingPeriod) { + $(this).slideUp(400); + } else { + if ( $slideSwitch ) { + $(this).slideDown(400); + if('year'==billingPeriod) { + // Make sure the year sync fields are visible + $syncAnnualContainer.slideDown(400); + // And the week/month field is hidden + $syncWeekMonthContainer.slideUp(400); + } else { + // Make sure the year sync fields are hidden + $syncAnnualContainer.slideUp(400); + // And the week/month field is visible + $syncWeekMonthContainer.slideDown(400); + } + } + } + }); + } + }, + moveSubscriptionVariationFields: function(){ + $('#variable_product_options .variable_subscription_pricing').not('wcs_moved').each(function(){ + var $regularPriceRow = $(this).siblings('.variable_pricing'), + $trialSignUpRow = $(this).siblings('.variable_subscription_trial_sign_up'), + $saleDatesRow; + + $saleDatesRow = $(this).siblings('.variable_pricing'); + + // Add the subscription price fields above the standard price fields + $(this).insertBefore($regularPriceRow); + + $trialSignUpRow.insertBefore($(this)); + + // Replace the regular price field with the trial period field + $regularPriceRow.children(':first').addClass('hide_if_variable-subscription'); + + $(this).addClass('wcs_moved'); + }); + }, + getVariationBulkEditValue: function(variation_action){ + var value; + + switch( variation_action ) { + case 'variable_subscription_period': + case 'variable_subscription_trial_period': + value = prompt( WCSubscriptions.bulkEditPeriodMessage ); + break; + case 'variable_subscription_period_interval': + value = prompt( WCSubscriptions.bulkEditIntervalhMessage ); + break; + case 'variable_subscription_trial_length': + case 'variable_subscription_length': + value = prompt( WCSubscriptions.bulkEditLengthMessage ); + break; + case 'variable_subscription_sign_up_fee': + value = prompt( woocommerce_admin_meta_boxes_variations.i18n_enter_a_value ); + value = accounting.unformat( value, woocommerce_admin.mon_decimal_point ); + break; + } + + return value; + }, + }); + + $('.options_group.pricing ._sale_price_field .description').prepend(''); + + // Move the subscription pricing section to the same location as the normal pricing section + $('.options_group.subscription_pricing').not('.variable_subscription_pricing .options_group.subscription_pricing').insertBefore($('.options_group.pricing:first')); + $('.show_if_subscription.clear').insertAfter($('.options_group.subscription_pricing')); + + // Move the subscription variation pricing section to a better location in the DOM on load + if($('#variable_product_options .variable_subscription_pricing').length > 0) { + $.moveSubscriptionVariationFields(); + } + // When a variation is added + $('#woocommerce-product-data').on('woocommerce_variations_added woocommerce_variations_loaded',function(){ + $.moveSubscriptionVariationFields(); + $.showHideVariableSubscriptionMeta(); + $.showHideSyncOptions(); + $.setSubscriptionLengths(); + }); + + if($('.options_group.pricing').length > 0) { + $.setSalePeriod(); + $.showHideSubscriptionMeta(); + $.showHideVariableSubscriptionMeta(); + $.setSubscriptionLengths(); + $.setTrialPeriods(); + $.showHideSyncOptions(); + } + + // Update subscription ranges when subscription period or interval is changed + $('#woocommerce-product-data').on('change','[name^="_subscription_period"], [name^="_subscription_period_interval"], [name^="variable_subscription_period"], [name^="variable_subscription_period_interval"]',function(){ + $.setSubscriptionLengths(); + $.showHideSyncOptions(); + $.setSyncOptions( $(this) ); + $.setSalePeriod(); + }); + + $('#woocommerce-product-data').on('propertychange keyup input paste change','[name^="_subscription_trial_length"], [name^="variable_subscription_trial_length"]',function(){ + $.setTrialPeriods(); + }); + + $('body').bind('woocommerce-product-type-change',function(){ + $.showHideSubscriptionMeta(); + $.showHideVariableSubscriptionMeta(); + $.showHideSyncOptions(); + }); + + $('input#_downloadable, input#_virtual').change(function(){ + $.showHideSubscriptionMeta(); + $.showHideVariableSubscriptionMeta(); + }); + + // Make sure the "Used for variations" checkbox is visible when adding attributes to a variable subscription + $('body').on('woocommerce_added_attribute', function(){ + $.showHideVariableSubscriptionMeta(); + }); + + if($.getParameterByName('select_subscription')=='true'){ + $('select#product-type option[value="'+WCSubscriptions.productType+'"]').attr('selected', 'selected'); + $('select#product-type').select().change(); + } + + // Before saving a subscription product, validate the trial period + $('#post').submit(function(e){ + if ( WCSubscriptions.subscriptionLengths !== undefined ){ + var trialLength = $('#_subscription_trial_length').val(), + selectedTrialPeriod = $('#_subscription_trial_period').val(); + + if ( parseInt(trialLength) >= WCSubscriptions.subscriptionLengths[selectedTrialPeriod].length ) { + alert(WCSubscriptions.trialTooLongMessages[selectedTrialPeriod]); + $('#ajax-loading').hide(); + $('#publish').removeClass('button-primary-disabled'); + e.preventDefault(); + } + } + }); + + // Notify store manager that deleting an order via the Orders screen also deletes subscriptions associated with the orders + $('#posts-filter').submit(function(){ + if($('[name="post_type"]').val()=='shop_order'&&($('[name="action"]').val()=='trash'||$('[name="action2"]').val()=='trash')){ + var containsSubscription = false; + $('[name="post[]"]:checked').each(function(){ + if(true===$('.contains_subscription',$('#post-'+$(this).val())).data('contains_subscription')){ + containsSubscription = true; + } + return (false === containsSubscription); + }); + if(containsSubscription){ + return confirm(WCSubscriptions.bulkTrashWarning); + } + } + }); + + $('.order_actions .submitdelete').click(function(){ + if($('[name="contains_subscription"]').val()=='true'){ + return confirm(WCSubscriptions.bulkTrashWarning); + } + }); + + $(window).load(function(){ + if($('[name="contains_subscription"]').length > 0 && $('[name="contains_subscription"]').val()=='true'){ + // Show the Recurring Order Totals meta box in WC 2.2 + $('#woocommerce-order-totals').show(); + } else { + $('#woocommerce-order-totals').hide(); + } + }); + + // Editing a variable product + $('#variable_product_options').on('change','[name^="variable_regular_price"]',function(){ + var matches = $(this).attr('name').match(/\[(.*?)\]/); + + if (matches) { + var loopIndex = matches[1]; + $('[name="variable_subscription_price['+loopIndex+']"]').val($(this).val()); + } + }); + + // Editing a variable product + $('#variable_product_options').on('change','[name^="variable_subscription_price"]',function(){ + var matches = $(this).attr('name').match(/\[(.*?)\]/); + + if (matches) { + var loopIndex = matches[1]; + $('[name="variable_regular_price['+loopIndex+']"]').val($(this).val()); + } + }); + + // Update hidden regular price when subscription price is update on simple products + $('#general_product_data').on('change', '[name^="_subscription_price"]', function() { + $('[name="_regular_price"]').val($(this).val()); + }); + + // Notify store manager that deleting an user via the Users screen also removed them from any subscriptions. + $('.users-php .submitdelete').on('click',function(){ + return confirm(WCSubscriptions.deleteUserWarning); + }); + + // WC 2.4+ variation bulk edit handling + $('select.variation_actions').on('variable_subscription_sign_up_fee_ajax_data variable_subscription_period_interval_ajax_data variable_subscription_period_ajax_data variable_subscription_trial_period_ajax_data variable_subscription_trial_length_ajax_data variable_subscription_length_ajax_data', function(event, data) { + value = $.getVariationBulkEditValue(event.type.replace(/_ajax_data/g,'')); + if ( value != null ) { + data.value = value; + } + return data; + }); + + // We're on the Subscriptions settings page + if($('#woocommerce_subscriptions_allow_switching').length > 0 ){ + var allowSwitching = $('#woocommerce_subscriptions_allow_switching').val(), + $switchSettingsRows = $('#woocommerce_subscriptions_allow_switching').parents('tr').siblings('tr'), + $syncProratationRow = $('#woocommerce_subscriptions_prorate_synced_payments').parents('tr'), + $suspensionExtensionRow = $('#woocommerce_subscriptions_recoup_suspension').parents('tr'); + + if('no'==allowSwitching){ + $switchSettingsRows.hide(); + } + + $('#woocommerce_subscriptions_allow_switching').on('change',function(){ + if('no'==$(this).val()){ + $switchSettingsRows.children('td, th').animate({paddingTop:0, paddingBottom:0}).wrapInner('
').children().slideUp(function(){ + $(this).closest('tr').hide(); + $(this).replaceWith($(this).html()); + }); + } else if('no'==allowSwitching) { // switching was previously disable, so settings will be hidden + $switchSettingsRows.fadeIn(); + $switchSettingsRows.children('td, th').css({paddingTop:0, paddingBottom:0}).animate({paddingTop:'15px', paddingBottom:'15px'}).wrapInner('
').children().slideDown(function(){ + $switchSettingsRows.children('td, th').removeAttr('style'); + $(this).replaceWith($(this).html()); + }); + } + allowSwitching = $(this).val(); + }); + + + // Show/hide suspension extension setting + if ($('#woocommerce_subscriptions_max_customer_suspensions').val() > 0) { + $suspensionExtensionRow.show(); + } else { + $suspensionExtensionRow.hide(); + } + + $('#woocommerce_subscriptions_max_customer_suspensions').on('change', function(){ + if ($(this).val() > 0) { + $suspensionExtensionRow.show(); + } else { + $suspensionExtensionRow.hide(); + } + }); + + // Show/hide sync proration setting + if ($('#woocommerce_subscriptions_sync_payments').is(':checked')) { + $syncProratationRow.show(); + } else { + $syncProratationRow.hide(); + } + + $('#woocommerce_subscriptions_sync_payments').on('change', function(){ + if ($(this).is(':checked')) { + $syncProratationRow.show(); + } else { + $syncProratationRow.hide(); + } + }); + } + + // Don't display the variation notice for variable subscription products + $( 'body' ).on( 'woocommerce-display-product-type-alert', function(e, select_val) { + if (select_val=='variable-subscription') { + return false; + } + }); + + $('.wcs_payment_method_selector').on('change', function() { + + var payment_method = $(this).val(); + + $('.wcs_payment_method_meta_fields').hide(); + $('#wcs_' + payment_method + '_fields').show(); + }); + +}); diff --git a/assets/js/admin/jstz.js b/assets/js/admin/jstz.js new file mode 100644 index 0000000..15e72e7 --- /dev/null +++ b/assets/js/admin/jstz.js @@ -0,0 +1,1313 @@ +(function (root) {/*global exports, Intl*/ +/** + * This script gives you the zone info key representing your device's time zone setting. + * + * @name jsTimezoneDetect + * @version 1.0.5 + * @author Jon Nylander + * @license MIT License - https://bitbucket.org/pellepim/jstimezonedetect/src/default/LICENCE.txt + * + * For usage and examples, visit: + * http://pellepim.bitbucket.org/jstz/ + * + * Copyright (c) Jon Nylander + */ + + +/** + * Namespace to hold all the code for timezone detection. + */ +var jstz = (function () { + 'use strict'; + var HEMISPHERE_SOUTH = 's', + + consts = { + DAY: 86400000, + HOUR: 3600000, + MINUTE: 60000, + SECOND: 1000, + BASELINE_YEAR: 2014, + MAX_SCORE: 864000000, // 10 days + AMBIGUITIES: { + 'America/Denver': ['America/Mazatlan'], + 'America/Chicago': ['America/Mexico_City'], + 'America/Santiago': ['America/Asuncion', 'America/Campo_Grande'], + 'America/Montevideo': ['America/Sao_Paulo'], + // Europe/Minsk should not be in this list... but Windows. + 'Asia/Beirut': ['Asia/Amman', 'Asia/Jerusalem', 'Europe/Helsinki', 'Asia/Damascus', 'Africa/Cairo', 'Asia/Gaza', 'Europe/Minsk'], + 'Pacific/Auckland': ['Pacific/Fiji'], + 'America/Los_Angeles': ['America/Santa_Isabel'], + 'America/New_York': ['America/Havana'], + 'America/Halifax': ['America/Goose_Bay'], + 'America/Godthab': ['America/Miquelon'], + 'Asia/Dubai': ['Asia/Yerevan'], + 'Asia/Jakarta': ['Asia/Krasnoyarsk'], + 'Asia/Shanghai': ['Asia/Irkutsk', 'Australia/Perth'], + 'Australia/Sydney': ['Australia/Lord_Howe'], + 'Asia/Tokyo': ['Asia/Yakutsk'], + 'Asia/Dhaka': ['Asia/Omsk'], + // In the real world Yerevan is not ambigous for Baku... but Windows. + 'Asia/Baku': ['Asia/Yerevan'], + 'Australia/Brisbane': ['Asia/Vladivostok'], + 'Pacific/Noumea': ['Asia/Vladivostok'], + 'Pacific/Majuro': ['Asia/Kamchatka', 'Pacific/Fiji'], + 'Pacific/Tongatapu': ['Pacific/Apia'], + 'Asia/Baghdad': ['Europe/Minsk', 'Europe/Moscow'], + 'Asia/Karachi': ['Asia/Yekaterinburg'], + 'Africa/Johannesburg': ['Asia/Gaza', 'Africa/Cairo'] + } + }, + + /** + * Gets the offset in minutes from UTC for a certain date. + * @param {Date} date + * @returns {Number} + */ + get_date_offset = function get_date_offset(date) { + var offset = -date.getTimezoneOffset(); + return (offset !== null ? offset : 0); + }, + + /** + * This function does some basic calculations to create information about + * the user's timezone. It uses REFERENCE_YEAR as a solid year for which + * the script has been tested rather than depend on the year set by the + * client device. + * + * Returns a key that can be used to do lookups in jstz.olson.timezones. + * eg: "720,1,2". + * + * @returns {String} + */ + lookup_key = function lookup_key() { + var january_offset = get_date_offset(new Date(consts.BASELINE_YEAR, 0, 2)), + june_offset = get_date_offset(new Date(consts.BASELINE_YEAR, 5, 2)), + diff = january_offset - june_offset; + + if (diff < 0) { + return january_offset + ",1"; + } else if (diff > 0) { + return june_offset + ",1," + HEMISPHERE_SOUTH; + } + + return january_offset + ",0"; + }, + + + /** + * Tries to get the time zone key directly from the operating system for those + * environments that support the ECMAScript Internationalization API. + */ + get_from_internationalization_api = function get_from_internationalization_api() { + if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat === "undefined") { + return; + } + var format = Intl.DateTimeFormat(); + if (typeof format === "undefined" || typeof format.resolvedOptions === "undefined") { + return; + } + return format.resolvedOptions().timeZone; + }, + + /** + * Starting point for getting all the DST rules for a specific year + * for the current timezone (as described by the client system). + * + * Returns an object with start and end attributes, or false if no + * DST rules were found for the year. + * + * @param year + * @returns {Object} || {Boolean} + */ + dst_dates = function dst_dates(year) { + var yearstart = new Date(year, 0, 1, 0, 0, 1, 0).getTime(); + var yearend = new Date(year, 12, 31, 23, 59, 59).getTime(); + var current = yearstart; + var offset = (new Date(current)).getTimezoneOffset(); + var dst_start = null; + var dst_end = null; + + while (current < yearend - 86400000) { + var dateToCheck = new Date(current); + var dateToCheckOffset = dateToCheck.getTimezoneOffset(); + + if (dateToCheckOffset !== offset) { + if (dateToCheckOffset < offset) { + dst_start = dateToCheck; + } + if (dateToCheckOffset > offset) { + dst_end = dateToCheck; + } + offset = dateToCheckOffset; + } + + current += 86400000; + } + + if (dst_start && dst_end) { + return { + s: find_dst_fold(dst_start).getTime(), + e: find_dst_fold(dst_end).getTime() + }; + } + + return false; + }, + + /** + * Probably completely unnecessary function that recursively finds the + * exact (to the second) time when a DST rule was changed. + * + * @param a_date - The candidate Date. + * @param padding - integer specifying the padding to allow around the candidate + * date for finding the fold. + * @param iterator - integer specifying how many milliseconds to iterate while + * searching for the fold. + * + * @returns {Date} + */ + find_dst_fold = function find_dst_fold(a_date, padding, iterator) { + if (typeof padding === 'undefined') { + padding = consts.DAY; + iterator = consts.HOUR; + } + + var date_start = new Date(a_date.getTime() - padding).getTime(); + var date_end = a_date.getTime() + padding; + var offset = new Date(date_start).getTimezoneOffset(); + + var current = date_start; + + var dst_change = null; + while (current < date_end - iterator) { + var dateToCheck = new Date(current); + var dateToCheckOffset = dateToCheck.getTimezoneOffset(); + + if (dateToCheckOffset !== offset) { + dst_change = dateToCheck; + break; + } + current += iterator; + } + + if (padding === consts.DAY) { + return find_dst_fold(dst_change, consts.HOUR, consts.MINUTE); + } + + if (padding === consts.HOUR) { + return find_dst_fold(dst_change, consts.MINUTE, consts.SECOND); + } + + return dst_change; + }, + + windows7_adaptations = function windows7_adaptions(rule_list, preliminary_timezone, score, sample) { + if (score !== 'N/A') { + return score; + } + if (preliminary_timezone === 'Asia/Beirut') { + if (sample.name === 'Africa/Cairo') { + if (rule_list[6].s === 1398376800000 && rule_list[6].e === 1411678800000) { + return 0; + } + } + if (sample.name === 'Asia/Jerusalem') { + if (rule_list[6].s === 1395964800000 && rule_list[6].e === 1411858800000) { + return 0; + } + } + } else if (preliminary_timezone === 'America/Santiago') { + if (sample.name === 'America/Asuncion') { + if (rule_list[6].s === 1412481600000 && rule_list[6].e === 1397358000000) { + return 0; + } + } + if (sample.name === 'America/Campo_Grande') { + if (rule_list[6].s === 1413691200000 && rule_list[6].e === 1392519600000) { + return 0; + } + } + } else if (preliminary_timezone === 'America/Montevideo') { + if (sample.name === 'America/Sao_Paulo') { + if (rule_list[6].s === 1413687600000 && rule_list[6].e === 1392516000000) { + return 0; + } + } + } else if (preliminary_timezone === 'Pacific/Auckland') { + if (sample.name === 'Pacific/Fiji') { + if (rule_list[6].s === 1414245600000 && rule_list[6].e === 1396101600000) { + return 0; + } + } + } + + return score; + }, + + /** + * Takes the DST rules for the current timezone, and proceeds to find matches + * in the jstz.olson.dst_rules.zones array. + * + * Compares samples to the current timezone on a scoring basis. + * + * Candidates are ruled immediately if either the candidate or the current zone + * has a DST rule where the other does not. + * + * Candidates are ruled out immediately if the current zone has a rule that is + * outside the DST scope of the candidate. + * + * Candidates are included for scoring if the current zones rules fall within the + * span of the samples rules. + * + * Low score is best, the score is calculated by summing up the differences in DST + * rules and if the consts.MAX_SCORE is overreached the candidate is ruled out. + * + * Yah follow? :) + * + * @param rule_list + * @param preliminary_timezone + * @returns {*} + */ + best_dst_match = function best_dst_match(rule_list, preliminary_timezone) { + var score_sample = function score_sample(sample) { + var score = 0; + + for (var j = 0; j < rule_list.length; j++) { + + // Both sample and current time zone report DST during the year. + if (!!sample.rules[j] && !!rule_list[j]) { + + // The current time zone's DST rules are inside the sample's. Include. + if (rule_list[j].s >= sample.rules[j].s && rule_list[j].e <= sample.rules[j].e) { + score = 0; + score += Math.abs(rule_list[j].s - sample.rules[j].s); + score += Math.abs(sample.rules[j].e - rule_list[j].e); + + // The current time zone's DST rules are outside the sample's. Discard. + } else { + score = 'N/A'; + break; + } + + // The max score has been reached. Discard. + if (score > consts.MAX_SCORE) { + score = 'N/A'; + break; + } + } + } + + score = windows7_adaptations(rule_list, preliminary_timezone, score, sample); + + return score; + }; + var scoreboard = {}; + var dst_zones = jstz.olson.dst_rules.zones; + var dst_zones_length = dst_zones.length; + var ambiguities = consts.AMBIGUITIES[preliminary_timezone]; + + for (var i = 0; i < dst_zones_length; i++) { + var sample = dst_zones[i]; + var score = score_sample(dst_zones[i]); + + if (score !== 'N/A') { + scoreboard[sample.name] = score; + } + } + + for (var tz in scoreboard) { + if (scoreboard.hasOwnProperty(tz)) { + if (ambiguities.indexOf(tz) != -1) { + return tz; + } + } + } + + return preliminary_timezone; + }, + + /** + * Takes the preliminary_timezone as detected by lookup_key(). + * + * Builds up the current timezones DST rules for the years defined + * in the jstz.olson.dst_rules.years array. + * + * If there are no DST occurences for those years, immediately returns + * the preliminary timezone. Otherwise proceeds and tries to solve + * ambiguities. + * + * @param preliminary_timezone + * @returns {String} timezone_name + */ + get_by_dst = function get_by_dst(preliminary_timezone) { + var get_rules = function get_rules() { + var rule_list = []; + for (var i = 0; i < jstz.olson.dst_rules.years.length; i++) { + var year_rules = dst_dates(jstz.olson.dst_rules.years[i]); + rule_list.push(year_rules); + } + return rule_list; + }; + var check_has_dst = function check_has_dst(rules) { + for (var i = 0; i < rules.length; i++) { + if (rules[i] !== false) { + return true; + } + } + return false; + }; + var rules = get_rules(); + var has_dst = check_has_dst(rules); + + if (has_dst) { + return best_dst_match(rules, preliminary_timezone); + } + + return preliminary_timezone; + }, + + /** + * Uses get_timezone_info() to formulate a key to use in the olson.timezones dictionary. + * + * Returns an object with one function ".name()" + * + * @returns Object + */ + determine = function determine() { + var preliminary_tz = get_from_internationalization_api(); + + if (!preliminary_tz) { + preliminary_tz = jstz.olson.timezones[lookup_key()]; + + if (typeof consts.AMBIGUITIES[preliminary_tz] !== 'undefined') { + preliminary_tz = get_by_dst(preliminary_tz); + } + } + + return { + name: function () { + return preliminary_tz; + } + }; + }; + + return { + determine: determine + }; +}()); + + +jstz.olson = jstz.olson || {}; + +/** + * The keys in this dictionary are comma separated as such: + * + * First the offset compared to UTC time in minutes. + * + * Then a flag which is 0 if the timezone does not take daylight savings into account and 1 if it + * does. + * + * Thirdly an optional 's' signifies that the timezone is in the southern hemisphere, + * only interesting for timezones with DST. + * + * The mapped arrays is used for constructing the jstz.TimeZone object from within + * jstz.determine(); + */ +jstz.olson.timezones = { + '-720,0': 'Etc/GMT+12', + '-660,0': 'Pacific/Pago_Pago', + '-660,1,s': 'Pacific/Apia', // Why? Because windows... cry! + '-600,1': 'America/Adak', + '-600,0': 'Pacific/Honolulu', + '-570,0': 'Pacific/Marquesas', + '-540,0': 'Pacific/Gambier', + '-540,1': 'America/Anchorage', + '-480,1': 'America/Los_Angeles', + '-480,0': 'Pacific/Pitcairn', + '-420,0': 'America/Phoenix', + '-420,1': 'America/Denver', + '-360,0': 'America/Guatemala', + '-360,1': 'America/Chicago', + '-360,1,s': 'Pacific/Easter', + '-300,0': 'America/Bogota', + '-300,1': 'America/New_York', + '-270,0': 'America/Caracas', + '-240,1': 'America/Halifax', + '-240,0': 'America/Santo_Domingo', + '-240,1,s': 'America/Santiago', + '-210,1': 'America/St_Johns', + '-180,1': 'America/Godthab', + '-180,0': 'America/Argentina/Buenos_Aires', + '-180,1,s': 'America/Montevideo', + '-120,0': 'America/Noronha', + '-120,1': 'America/Noronha', + '-60,1': 'Atlantic/Azores', + '-60,0': 'Atlantic/Cape_Verde', + '0,0': 'UTC', + '0,1': 'Europe/London', + '60,1': 'Europe/Berlin', + '60,0': 'Africa/Lagos', + '60,1,s': 'Africa/Windhoek', + '120,1': 'Asia/Beirut', + '120,0': 'Africa/Johannesburg', + '180,0': 'Asia/Baghdad', + '180,1': 'Europe/Moscow', + '210,1': 'Asia/Tehran', + '240,0': 'Asia/Dubai', + '240,1': 'Asia/Baku', + '270,0': 'Asia/Kabul', + '300,1': 'Asia/Yekaterinburg', + '300,0': 'Asia/Karachi', + '330,0': 'Asia/Kolkata', + '345,0': 'Asia/Kathmandu', + '360,0': 'Asia/Dhaka', + '360,1': 'Asia/Omsk', + '390,0': 'Asia/Rangoon', + '420,1': 'Asia/Krasnoyarsk', + '420,0': 'Asia/Jakarta', + '480,0': 'Asia/Shanghai', + '480,1': 'Asia/Irkutsk', + '525,0': 'Australia/Eucla', + '525,1,s': 'Australia/Eucla', + '540,1': 'Asia/Yakutsk', + '540,0': 'Asia/Tokyo', + '570,0': 'Australia/Darwin', + '570,1,s': 'Australia/Adelaide', + '600,0': 'Australia/Brisbane', + '600,1': 'Asia/Vladivostok', + '600,1,s': 'Australia/Sydney', + '630,1,s': 'Australia/Lord_Howe', + '660,1': 'Asia/Kamchatka', + '660,0': 'Pacific/Noumea', + '690,0': 'Pacific/Norfolk', + '720,1,s': 'Pacific/Auckland', + '720,0': 'Pacific/Majuro', + '765,1,s': 'Pacific/Chatham', + '780,0': 'Pacific/Tongatapu', + '780,1,s': 'Pacific/Apia', + '840,0': 'Pacific/Kiritimati' +}; + +/* Build time: 2014-11-28 11:10:50Z Build by invoking python utilities/dst.py generate */ +jstz.olson.dst_rules = { + "years": [ + 2008, + 2009, + 2010, + 2011, + 2012, + 2013, + 2014 + ], + "zones": [ + { + "name": "Africa/Cairo", + "rules": [ + { + "e": 1219957200000, + "s": 1209074400000 + }, + { + "e": 1250802000000, + "s": 1240524000000 + }, + { + "e": 1285880400000, + "s": 1284069600000 + }, + false, + false, + false, + { + "e": 1411678800000, + "s": 1406844000000 + } + ] + }, + { + "name": "America/Asuncion", + "rules": [ + { + "e": 1205031600000, + "s": 1224388800000 + }, + { + "e": 1236481200000, + "s": 1255838400000 + }, + { + "e": 1270954800000, + "s": 1286078400000 + }, + { + "e": 1302404400000, + "s": 1317528000000 + }, + { + "e": 1333854000000, + "s": 1349582400000 + }, + { + "e": 1364094000000, + "s": 1381032000000 + }, + { + "e": 1395543600000, + "s": 1412481600000 + } + ] + }, + { + "name": "America/Campo_Grande", + "rules": [ + { + "e": 1203217200000, + "s": 1224388800000 + }, + { + "e": 1234666800000, + "s": 1255838400000 + }, + { + "e": 1266721200000, + "s": 1287288000000 + }, + { + "e": 1298170800000, + "s": 1318737600000 + }, + { + "e": 1330225200000, + "s": 1350792000000 + }, + { + "e": 1361070000000, + "s": 1382241600000 + }, + { + "e": 1392519600000, + "s": 1413691200000 + } + ] + }, + { + "name": "America/Goose_Bay", + "rules": [ + { + "e": 1225594860000, + "s": 1205035260000 + }, + { + "e": 1257044460000, + "s": 1236484860000 + }, + { + "e": 1289098860000, + "s": 1268539260000 + }, + { + "e": 1320555600000, + "s": 1299988860000 + }, + { + "e": 1352005200000, + "s": 1331445600000 + }, + { + "e": 1383454800000, + "s": 1362895200000 + }, + { + "e": 1414904400000, + "s": 1394344800000 + } + ] + }, + { + "name": "America/Havana", + "rules": [ + { + "e": 1224997200000, + "s": 1205643600000 + }, + { + "e": 1256446800000, + "s": 1236488400000 + }, + { + "e": 1288501200000, + "s": 1268542800000 + }, + { + "e": 1321160400000, + "s": 1300597200000 + }, + { + "e": 1352005200000, + "s": 1333256400000 + }, + { + "e": 1383454800000, + "s": 1362891600000 + }, + { + "e": 1414904400000, + "s": 1394341200000 + } + ] + }, + { + "name": "America/Mazatlan", + "rules": [ + { + "e": 1225008000000, + "s": 1207472400000 + }, + { + "e": 1256457600000, + "s": 1238922000000 + }, + { + "e": 1288512000000, + "s": 1270371600000 + }, + { + "e": 1319961600000, + "s": 1301821200000 + }, + { + "e": 1351411200000, + "s": 1333270800000 + }, + { + "e": 1382860800000, + "s": 1365325200000 + }, + { + "e": 1414310400000, + "s": 1396774800000 + } + ] + }, + { + "name": "America/Mexico_City", + "rules": [ + { + "e": 1225004400000, + "s": 1207468800000 + }, + { + "e": 1256454000000, + "s": 1238918400000 + }, + { + "e": 1288508400000, + "s": 1270368000000 + }, + { + "e": 1319958000000, + "s": 1301817600000 + }, + { + "e": 1351407600000, + "s": 1333267200000 + }, + { + "e": 1382857200000, + "s": 1365321600000 + }, + { + "e": 1414306800000, + "s": 1396771200000 + } + ] + }, + { + "name": "America/Miquelon", + "rules": [ + { + "e": 1225598400000, + "s": 1205038800000 + }, + { + "e": 1257048000000, + "s": 1236488400000 + }, + { + "e": 1289102400000, + "s": 1268542800000 + }, + { + "e": 1320552000000, + "s": 1299992400000 + }, + { + "e": 1352001600000, + "s": 1331442000000 + }, + { + "e": 1383451200000, + "s": 1362891600000 + }, + { + "e": 1414900800000, + "s": 1394341200000 + } + ] + }, + { + "name": "America/Santa_Isabel", + "rules": [ + { + "e": 1225011600000, + "s": 1207476000000 + }, + { + "e": 1256461200000, + "s": 1238925600000 + }, + { + "e": 1288515600000, + "s": 1270375200000 + }, + { + "e": 1319965200000, + "s": 1301824800000 + }, + { + "e": 1351414800000, + "s": 1333274400000 + }, + { + "e": 1382864400000, + "s": 1365328800000 + }, + { + "e": 1414314000000, + "s": 1396778400000 + } + ] + }, + { + "name": "America/Sao_Paulo", + "rules": [ + { + "e": 1203213600000, + "s": 1224385200000 + }, + { + "e": 1234663200000, + "s": 1255834800000 + }, + { + "e": 1266717600000, + "s": 1287284400000 + }, + { + "e": 1298167200000, + "s": 1318734000000 + }, + { + "e": 1330221600000, + "s": 1350788400000 + }, + { + "e": 1361066400000, + "s": 1382238000000 + }, + { + "e": 1392516000000, + "s": 1413687600000 + } + ] + }, + { + "name": "Asia/Amman", + "rules": [ + { + "e": 1225404000000, + "s": 1206655200000 + }, + { + "e": 1256853600000, + "s": 1238104800000 + }, + { + "e": 1288303200000, + "s": 1269554400000 + }, + { + "e": 1319752800000, + "s": 1301608800000 + }, + false, + false, + { + "e": 1414706400000, + "s": 1395957600000 + } + ] + }, + { + "name": "Asia/Damascus", + "rules": [ + { + "e": 1225486800000, + "s": 1207260000000 + }, + { + "e": 1256850000000, + "s": 1238104800000 + }, + { + "e": 1288299600000, + "s": 1270159200000 + }, + { + "e": 1319749200000, + "s": 1301608800000 + }, + { + "e": 1351198800000, + "s": 1333058400000 + }, + { + "e": 1382648400000, + "s": 1364508000000 + }, + { + "e": 1414702800000, + "s": 1395957600000 + } + ] + }, + { + "name": "Asia/Dubai", + "rules": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Gaza", + "rules": [ + { + "e": 1219957200000, + "s": 1206655200000 + }, + { + "e": 1252015200000, + "s": 1238104800000 + }, + { + "e": 1281474000000, + "s": 1269640860000 + }, + { + "e": 1312146000000, + "s": 1301608860000 + }, + { + "e": 1348178400000, + "s": 1333058400000 + }, + { + "e": 1380229200000, + "s": 1364508000000 + }, + { + "e": 1411678800000, + "s": 1395957600000 + } + ] + }, + { + "name": "Asia/Irkutsk", + "rules": [ + { + "e": 1224957600000, + "s": 1206813600000 + }, + { + "e": 1256407200000, + "s": 1238263200000 + }, + { + "e": 1288461600000, + "s": 1269712800000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Jerusalem", + "rules": [ + { + "e": 1223161200000, + "s": 1206662400000 + }, + { + "e": 1254006000000, + "s": 1238112000000 + }, + { + "e": 1284246000000, + "s": 1269561600000 + }, + { + "e": 1317510000000, + "s": 1301616000000 + }, + { + "e": 1348354800000, + "s": 1333065600000 + }, + { + "e": 1382828400000, + "s": 1364515200000 + }, + { + "e": 1414278000000, + "s": 1395964800000 + } + ] + }, + { + "name": "Asia/Kamchatka", + "rules": [ + { + "e": 1224943200000, + "s": 1206799200000 + }, + { + "e": 1256392800000, + "s": 1238248800000 + }, + { + "e": 1288450800000, + "s": 1269698400000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Krasnoyarsk", + "rules": [ + { + "e": 1224961200000, + "s": 1206817200000 + }, + { + "e": 1256410800000, + "s": 1238266800000 + }, + { + "e": 1288465200000, + "s": 1269716400000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Omsk", + "rules": [ + { + "e": 1224964800000, + "s": 1206820800000 + }, + { + "e": 1256414400000, + "s": 1238270400000 + }, + { + "e": 1288468800000, + "s": 1269720000000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Vladivostok", + "rules": [ + { + "e": 1224950400000, + "s": 1206806400000 + }, + { + "e": 1256400000000, + "s": 1238256000000 + }, + { + "e": 1288454400000, + "s": 1269705600000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Yakutsk", + "rules": [ + { + "e": 1224954000000, + "s": 1206810000000 + }, + { + "e": 1256403600000, + "s": 1238259600000 + }, + { + "e": 1288458000000, + "s": 1269709200000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Yekaterinburg", + "rules": [ + { + "e": 1224968400000, + "s": 1206824400000 + }, + { + "e": 1256418000000, + "s": 1238274000000 + }, + { + "e": 1288472400000, + "s": 1269723600000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Asia/Yerevan", + "rules": [ + { + "e": 1224972000000, + "s": 1206828000000 + }, + { + "e": 1256421600000, + "s": 1238277600000 + }, + { + "e": 1288476000000, + "s": 1269727200000 + }, + { + "e": 1319925600000, + "s": 1301176800000 + }, + false, + false, + false + ] + }, + { + "name": "Australia/Lord_Howe", + "rules": [ + { + "e": 1207407600000, + "s": 1223134200000 + }, + { + "e": 1238857200000, + "s": 1254583800000 + }, + { + "e": 1270306800000, + "s": 1286033400000 + }, + { + "e": 1301756400000, + "s": 1317483000000 + }, + { + "e": 1333206000000, + "s": 1349537400000 + }, + { + "e": 1365260400000, + "s": 1380987000000 + }, + { + "e": 1396710000000, + "s": 1412436600000 + } + ] + }, + { + "name": "Australia/Perth", + "rules": [ + { + "e": 1206813600000, + "s": 1224957600000 + }, + false, + false, + false, + false, + false, + false + ] + }, + { + "name": "Europe/Helsinki", + "rules": [ + { + "e": 1224982800000, + "s": 1206838800000 + }, + { + "e": 1256432400000, + "s": 1238288400000 + }, + { + "e": 1288486800000, + "s": 1269738000000 + }, + { + "e": 1319936400000, + "s": 1301187600000 + }, + { + "e": 1351386000000, + "s": 1332637200000 + }, + { + "e": 1382835600000, + "s": 1364691600000 + }, + { + "e": 1414285200000, + "s": 1396141200000 + } + ] + }, + { + "name": "Europe/Minsk", + "rules": [ + { + "e": 1224979200000, + "s": 1206835200000 + }, + { + "e": 1256428800000, + "s": 1238284800000 + }, + { + "e": 1288483200000, + "s": 1269734400000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Europe/Moscow", + "rules": [ + { + "e": 1224975600000, + "s": 1206831600000 + }, + { + "e": 1256425200000, + "s": 1238281200000 + }, + { + "e": 1288479600000, + "s": 1269730800000 + }, + false, + false, + false, + false + ] + }, + { + "name": "Pacific/Apia", + "rules": [ + false, + false, + false, + { + "e": 1301752800000, + "s": 1316872800000 + }, + { + "e": 1333202400000, + "s": 1348927200000 + }, + { + "e": 1365256800000, + "s": 1380376800000 + }, + { + "e": 1396706400000, + "s": 1411826400000 + } + ] + }, + { + "name": "Pacific/Fiji", + "rules": [ + false, + false, + { + "e": 1269698400000, + "s": 1287842400000 + }, + { + "e": 1327154400000, + "s": 1319292000000 + }, + { + "e": 1358604000000, + "s": 1350741600000 + }, + { + "e": 1390050000000, + "s": 1382796000000 + }, + { + "e": 1421503200000, + "s": 1414850400000 + } + ] + } + ] +}; if (typeof exports !== 'undefined') { + exports.jstz = jstz; + } else { + root.jstz = jstz; + } +})(this); diff --git a/assets/js/admin/jstz.min.js b/assets/js/admin/jstz.min.js new file mode 100644 index 0000000..2483bd0 --- /dev/null +++ b/assets/js/admin/jstz.min.js @@ -0,0 +1,2 @@ +/* jstz.min.js Version: 1.0.5 Build date: 2014-10-28 */ +!function(e){var a=function(){"use strict";var e="s",s={DAY:864e5,HOUR:36e5,MINUTE:6e4,SECOND:1e3,BASELINE_YEAR:2014,MAX_SCORE:864e6,AMBIGUITIES:{"America/Denver":["America/Mazatlan"],"America/Chicago":["America/Mexico_City"],"America/Santiago":["America/Asuncion","America/Campo_Grande"],"America/Montevideo":["America/Sao_Paulo"],"Asia/Beirut":["Asia/Amman","Asia/Jerusalem","Europe/Helsinki","Asia/Damascus","Africa/Cairo","Asia/Gaza","Europe/Minsk"],"Pacific/Auckland":["Pacific/Fiji"],"America/Los_Angeles":["America/Santa_Isabel"],"America/New_York":["America/Havana"],"America/Halifax":["America/Goose_Bay"],"America/Godthab":["America/Miquelon"],"Asia/Dubai":["Asia/Yerevan"],"Asia/Jakarta":["Asia/Krasnoyarsk"],"Asia/Shanghai":["Asia/Irkutsk","Australia/Perth"],"Australia/Sydney":["Australia/Lord_Howe"],"Asia/Tokyo":["Asia/Yakutsk"],"Asia/Dhaka":["Asia/Omsk"],"Asia/Baku":["Asia/Yerevan"],"Australia/Brisbane":["Asia/Vladivostok"],"Pacific/Noumea":["Asia/Vladivostok"],"Pacific/Majuro":["Asia/Kamchatka","Pacific/Fiji"],"Pacific/Tongatapu":["Pacific/Apia"],"Asia/Baghdad":["Europe/Minsk","Europe/Moscow"],"Asia/Karachi":["Asia/Yekaterinburg"],"Africa/Johannesburg":["Asia/Gaza","Africa/Cairo"]}},i=function(e){var a=-e.getTimezoneOffset();return null!==a?a:0},r=function(){var a=i(new Date(s.BASELINE_YEAR,0,2)),r=i(new Date(s.BASELINE_YEAR,5,2)),n=a-r;return 0>n?a+",1":n>0?r+",1,"+e:a+",0"},n=function(){if("undefined"!=typeof Intl&&"undefined"!=typeof Intl.DateTimeFormat){var e=Intl.DateTimeFormat();if("undefined"!=typeof e&&"undefined"!=typeof e.resolvedOptions)return e.resolvedOptions().timeZone}},o=function(e){for(var a=new Date(e,0,1,0,0,1,0).getTime(),s=new Date(e,12,31,23,59,59).getTime(),i=a,r=new Date(i).getTimezoneOffset(),n=null,o=null;s-864e5>i;){var A=new Date(i),u=A.getTimezoneOffset();u!==r&&(r>u&&(n=A),u>r&&(o=A),r=u),i+=864e5}return n&&o?{s:t(n).getTime(),e:t(o).getTime()}:!1},t=function l(e,a,i){"undefined"==typeof a&&(a=s.DAY,i=s.HOUR);for(var r=new Date(e.getTime()-a).getTime(),n=e.getTime()+a,o=new Date(r).getTimezoneOffset(),t=r,A=null;n-i>t;){var u=new Date(t),c=u.getTimezoneOffset();if(c!==o){A=u;break}t+=i}return a===s.DAY?l(A,s.HOUR,s.MINUTE):a===s.HOUR?l(A,s.MINUTE,s.SECOND):A},A=function(e,a,s,i){if("N/A"!==s)return s;if("Asia/Beirut"===a){if("Africa/Cairo"===i.name&&13983768e5===e[6].s&&14116788e5===e[6].e)return 0;if("Asia/Jerusalem"===i.name&&13959648e5===e[6].s&&14118588e5===e[6].e)return 0}else if("America/Santiago"===a){if("America/Asuncion"===i.name&&14124816e5===e[6].s&&1397358e6===e[6].e)return 0;if("America/Campo_Grande"===i.name&&14136912e5===e[6].s&&13925196e5===e[6].e)return 0}else if("America/Montevideo"===a){if("America/Sao_Paulo"===i.name&&14136876e5===e[6].s&&1392516e6===e[6].e)return 0}else if("Pacific/Auckland"===a&&"Pacific/Fiji"===i.name&&14142456e5===e[6].s&&13961016e5===e[6].e)return 0;return s},u=function(e,i){for(var r=function(a){for(var r=0,n=0;n=a.rules[n].s&&e[n].e<=a.rules[n].e)){r="N/A";break}if(r=0,r+=Math.abs(e[n].s-a.rules[n].s),r+=Math.abs(a.rules[n].e-e[n].e),r>s.MAX_SCORE){r="N/A";break}}return r=A(e,i,r,a)},n={},o=a.olson.dst_rules.zones,t=o.length,u=s.AMBIGUITIES[i],c=0;t>c;c++){var m=o[c],l=r(o[c]);"N/A"!==l&&(n[m.name]=l)}for(var f in n)if(n.hasOwnProperty(f)&&-1!=u.indexOf(f))return f;return i},c=function(e){var s=function(){for(var e=[],s=0;s 0 ) { + date.local(); + $date_input.val( date.year() + '-' + ( zeroise( date.months() + 1 ) ) + '-' + ( date.format( 'DD' ) ) ); + $hour_input.val( date.format( 'HH' ) ); + $minute_input.val( date.format( 'mm' ) ); + } + }); + + // Make sure date pickers are in the future + $( '.woocommerce-subscriptions.date-picker:not(#start)' ).datepicker( 'option','minDate',moment().add(1,'hours').toDate()); + + // Validate date when hour/minute inputs change + $( '[name$="_hour"], [name$="_minute"]' ).on( 'change', function() { + $( '#' + $(this).attr( 'name' ).replace( '_hour', '' ).replace( '_minute', '' ) ).change(); + }); + + // Validate entire date + $( '.woocommerce-subscriptions.date-picker' ).on( 'change',function(){ + + // The date was deleted, clear hour/minute inputs values and set the UTC timestamp to 0 + if( '' == $(this).val() ) { + $( '#' + $(this).attr( 'id' ) + '_hour' ).val(''); + $( '#' + $(this).attr( 'id' ) + '_minute' ).val(''); + $( '#' + $(this).attr( 'id' ) + '_timestamp_utc' ).val(0); + return; + } + + var time_now = moment(), + one_hour_from_now = moment().add(1,'hours' ), + $date_input = $(this), + date_type = $date_input.attr( 'id' ), + date_pieces = $date_input.val().split( '-' ), + $hour_input = $( '#'+date_type+'_hour' ), + $minute_input = $( '#'+date_type+'_minute' ), + chosen_hour = (0 == $hour_input.val().length) ? one_hour_from_now.format( 'HH' ) : $hour_input.val(), + chosen_minute = (0 == $minute_input.val().length) ? one_hour_from_now.format( 'mm' ) : $minute_input.val(), + chosen_date = moment({ + years: date_pieces[0], + months: (date_pieces[1] - 1), + date: (date_pieces[2]), + hours: chosen_hour, + minutes: chosen_minute, + seconds: one_hour_from_now.format( 'ss' ) + }); + + + // Make sure start date is before now + if ( 'start' == date_type ) { + + if ( false === chosen_date.isBefore( time_now ) ) { + alert( wcs_admin_meta_boxes.i18n_start_date_notice ); + $date_input.val( time_now.year() + '-' + ( zeroise( time_now.months() + 1 ) ) + '-' + ( time_now.format( 'DD' ) ) ); + $hour_input.val( time_now.format( 'HH' ) ); + $minute_input.val( time_now.format( 'mm' ) ); + } + + } + + // Make sure trial end and next payment are after start date + else if ( ( 'trial_end' == date_type || 'next_payment' == date_type ) && '' != $( '#start_timestamp_utc' ).val() ) { + var change_date = false, + start = moment.unix( $('#start_timestamp_utc').val() ); + + // Make sure trial end is after start date + if ( 'trial_end' == date_type && chosen_date.isBefore( start, 'minute' ) ) { + + if ( 'trial_end' == date_type ) { + alert( wcs_admin_meta_boxes.i18n_trial_end_start_notice ); + } else if ( 'next_payment' == date_type ) { + alert( wcs_admin_meta_boxes.i18n_next_payment_start_notice ); + } + + // Change the date + $date_input.val( start.year() + '-' + ( zeroise( start.months() + 1 ) ) + '-' + ( start.format( 'DD' ) ) ); + $hour_input.val( start.format( 'HH' ) ); + $minute_input.val( start.format( 'mm' ) ); + } + } + + // Make sure next payment is after trial end + if ( 'next_payment' == date_type && '' != $( '#trial_end_timestamp_utc' ).val() ) { + var trial_end = moment.unix( $('#trial_end_timestamp_utc').val() ); + + if ( chosen_date.isBefore( trial_end, 'minute' ) ) { + alert( wcs_admin_meta_boxes.i18n_next_payment_trial_notice ); + $date_input.val( trial_end.year() + '-' + ( zeroise( trial_end.months() + 1 ) ) + '-' + ( trial_end.format( 'DD' ) ) ); + $hour_input.val( trial_end.format( 'HH' ) ); + $minute_input.val( trial_end.format( 'mm' ) ); + } + } + + // Make sure trial end is before next payment and expiration is after next payment date + else if ( ( 'trial_end' == date_type || 'end' == date_type ) && '' != $( '#next_payment' ).val() ) { + var change_date = false, + next_payment = moment.unix( $('#next_payment_timestamp_utc').val() ); + + // Make sure trial end is before or equal to next payment + if ( 'trial_end' == date_type && next_payment.isBefore( chosen_date, 'minute' ) ) { + alert( wcs_admin_meta_boxes.i18n_trial_end_next_notice ); + change_date = true; + } + // Make sure end date is after next payment date + else if ( 'end' == date_type && chosen_date.isBefore( next_payment, 'minute' ) ) { + alert( wcs_admin_meta_boxes.i18n_end_date_notice ); + change_date = true; + } + + if ( true === change_date ) { + $date_input.val( next_payment.year() + '-' + ( zeroise( next_payment.months() + 1 ) ) + '-' + ( next_payment.format( 'DD' ) ) ); + $hour_input.val( next_payment.format( 'HH' ) ); + $minute_input.val( next_payment.format( 'mm' ) ); + } + } + + // Make sure the date is more than an hour in the future + if ( 'trial_end' != date_type && 'start' != date_type && chosen_date.unix() < one_hour_from_now.unix() ) { + + alert( wcs_admin_meta_boxes.i18n_past_date_notice ); + + // Set date to current day + $date_input.val( one_hour_from_now.year() + '-' + ( zeroise( one_hour_from_now.months() + 1 ) ) + '-' + ( one_hour_from_now.format( 'DD' ) ) ); + + // Set time if current time is in the past + if ( chosen_date.hours() < one_hour_from_now.hours() || ( chosen_date.hours() == one_hour_from_now.hours() && chosen_date.minutes() < one_hour_from_now.minutes() ) ) { + $hour_input.val( one_hour_from_now.format( 'HH' ) ); + $minute_input.val( one_hour_from_now.format( 'mm' ) ); + } + } + + if( 0 == $hour_input.val().length ){ + $hour_input.val(one_hour_from_now.format( 'HH' )); + } + + if( 0 == $minute_input.val().length ){ + $minute_input.val(one_hour_from_now.format( 'mm' )); + } + + // Update the UTC timestamp sent to the server + date_pieces = $date_input.val().split( '-' ); + + $('#'+date_type+'_timestamp_utc').val(moment({ + years: date_pieces[0], + months: (date_pieces[1] - 1), + date: (date_pieces[2]), + hours: $hour_input.val(), + minutes: $minute_input.val(), + seconds: one_hour_from_now.format( 'ss' ) + }).utc().unix()); + + $( 'body' ).trigger( 'wcs-updated-date',date_type); + }); + + function zeroise( val ) { + return (val > 9 ) ? val : '0' + val; + } + + $('body.post-type-shop_subscription #post').submit(function(){ + if('wcs_process_renewal' == $( "body.post-type-shop_subscription select[name='wc_order_action']" ).val()) { + return confirm(wcs_admin_meta_boxes.process_renewal_action_warning); + } + }); + + $('body.post-type-shop_subscription #post').submit(function(){ + if ( typeof wcs_admin_meta_boxes.change_payment_method_warning != 'undefined' && wcs_admin_meta_boxes.payment_method != $('#_payment_method').val() ) { + return confirm(wcs_admin_meta_boxes.change_payment_method_warning); + } + }); +}); diff --git a/assets/js/admin/moment.js b/assets/js/admin/moment.js new file mode 100644 index 0000000..cf8bb30 --- /dev/null +++ b/assets/js/admin/moment.js @@ -0,0 +1,2936 @@ +//! moment.js +//! version : 2.8.4 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com + +(function (undefined) { + /************************************ + Constants + ************************************/ + + var moment, + VERSION = '2.8.4', + // the global-scope this is NOT the global object in Node.js + globalScope = typeof global !== 'undefined' ? global : this, + oldGlobalMoment, + round = Math.round, + hasOwnProperty = Object.prototype.hasOwnProperty, + i, + + YEAR = 0, + MONTH = 1, + DATE = 2, + HOUR = 3, + MINUTE = 4, + SECOND = 5, + MILLISECOND = 6, + + // internal storage for locale config files + locales = {}, + + // extra moment internal properties (plugins register props here) + momentProperties = [], + + // check for nodeJS + hasModule = (typeof module !== 'undefined' && module && module.exports), + + // ASP.NET json date format regex + aspNetJsonRegex = /^\/?Date\((\-?\d+)/i, + aspNetTimeSpanJsonRegex = /(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/, + + // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html + // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere + isoDurationRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/, + + // format tokens + formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|x|X|zz?|ZZ?|.)/g, + localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, + + // parsing token regexes + parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99 + parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999 + parseTokenOneToFourDigits = /\d{1,4}/, // 0 - 9999 + parseTokenOneToSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999 + parseTokenDigits = /\d+/, // nonzero number of digits + parseTokenWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i, // any word (or two) characters or numbers including two/three word month in arabic. + parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z + parseTokenT = /T/i, // T (ISO separator) + parseTokenOffsetMs = /[\+\-]?\d+/, // 1234567890123 + parseTokenTimestampMs = /[\+\-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123 + + //strict parsing regexes + parseTokenOneDigit = /\d/, // 0 - 9 + parseTokenTwoDigits = /\d\d/, // 00 - 99 + parseTokenThreeDigits = /\d{3}/, // 000 - 999 + parseTokenFourDigits = /\d{4}/, // 0000 - 9999 + parseTokenSixDigits = /[+-]?\d{6}/, // -999,999 - 999,999 + parseTokenSignedNumber = /[+-]?\d+/, // -inf - inf + + // iso 8601 regex + // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00) + isoRegex = /^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + + isoFormat = 'YYYY-MM-DDTHH:mm:ssZ', + + isoDates = [ + ['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/], + ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/], + ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/], + ['GGGG-[W]WW', /\d{4}-W\d{2}/], + ['YYYY-DDD', /\d{4}-\d{3}/] + ], + + // iso time formats and regexes + isoTimes = [ + ['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d+/], + ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/], + ['HH:mm', /(T| )\d\d:\d\d/], + ['HH', /(T| )\d\d/] + ], + + // timezone chunker '+10:00' > ['10', '00'] or '-1530' > ['-15', '30'] + parseTimezoneChunker = /([\+\-]|\d\d)/gi, + + // getter and setter names + proxyGettersAndSetters = 'Date|Hours|Minutes|Seconds|Milliseconds'.split('|'), + unitMillisecondFactors = { + 'Milliseconds' : 1, + 'Seconds' : 1e3, + 'Minutes' : 6e4, + 'Hours' : 36e5, + 'Days' : 864e5, + 'Months' : 2592e6, + 'Years' : 31536e6 + }, + + unitAliases = { + ms : 'millisecond', + s : 'second', + m : 'minute', + h : 'hour', + d : 'day', + D : 'date', + w : 'week', + W : 'isoWeek', + M : 'month', + Q : 'quarter', + y : 'year', + DDD : 'dayOfYear', + e : 'weekday', + E : 'isoWeekday', + gg: 'weekYear', + GG: 'isoWeekYear' + }, + + camelFunctions = { + dayofyear : 'dayOfYear', + isoweekday : 'isoWeekday', + isoweek : 'isoWeek', + weekyear : 'weekYear', + isoweekyear : 'isoWeekYear' + }, + + // format function strings + formatFunctions = {}, + + // default relative time thresholds + relativeTimeThresholds = { + s: 45, // seconds to minute + m: 45, // minutes to hour + h: 22, // hours to day + d: 26, // days to month + M: 11 // months to year + }, + + // tokens to ordinalize and pad + ordinalizeTokens = 'DDD w W M D d'.split(' '), + paddedTokens = 'M D H h m s w W'.split(' '), + + formatTokenFunctions = { + M : function () { + return this.month() + 1; + }, + MMM : function (format) { + return this.localeData().monthsShort(this, format); + }, + MMMM : function (format) { + return this.localeData().months(this, format); + }, + D : function () { + return this.date(); + }, + DDD : function () { + return this.dayOfYear(); + }, + d : function () { + return this.day(); + }, + dd : function (format) { + return this.localeData().weekdaysMin(this, format); + }, + ddd : function (format) { + return this.localeData().weekdaysShort(this, format); + }, + dddd : function (format) { + return this.localeData().weekdays(this, format); + }, + w : function () { + return this.week(); + }, + W : function () { + return this.isoWeek(); + }, + YY : function () { + return leftZeroFill(this.year() % 100, 2); + }, + YYYY : function () { + return leftZeroFill(this.year(), 4); + }, + YYYYY : function () { + return leftZeroFill(this.year(), 5); + }, + YYYYYY : function () { + var y = this.year(), sign = y >= 0 ? '+' : '-'; + return sign + leftZeroFill(Math.abs(y), 6); + }, + gg : function () { + return leftZeroFill(this.weekYear() % 100, 2); + }, + gggg : function () { + return leftZeroFill(this.weekYear(), 4); + }, + ggggg : function () { + return leftZeroFill(this.weekYear(), 5); + }, + GG : function () { + return leftZeroFill(this.isoWeekYear() % 100, 2); + }, + GGGG : function () { + return leftZeroFill(this.isoWeekYear(), 4); + }, + GGGGG : function () { + return leftZeroFill(this.isoWeekYear(), 5); + }, + e : function () { + return this.weekday(); + }, + E : function () { + return this.isoWeekday(); + }, + a : function () { + return this.localeData().meridiem(this.hours(), this.minutes(), true); + }, + A : function () { + return this.localeData().meridiem(this.hours(), this.minutes(), false); + }, + H : function () { + return this.hours(); + }, + h : function () { + return this.hours() % 12 || 12; + }, + m : function () { + return this.minutes(); + }, + s : function () { + return this.seconds(); + }, + S : function () { + return toInt(this.milliseconds() / 100); + }, + SS : function () { + return leftZeroFill(toInt(this.milliseconds() / 10), 2); + }, + SSS : function () { + return leftZeroFill(this.milliseconds(), 3); + }, + SSSS : function () { + return leftZeroFill(this.milliseconds(), 3); + }, + Z : function () { + var a = -this.zone(), + b = '+'; + if (a < 0) { + a = -a; + b = '-'; + } + return b + leftZeroFill(toInt(a / 60), 2) + ':' + leftZeroFill(toInt(a) % 60, 2); + }, + ZZ : function () { + var a = -this.zone(), + b = '+'; + if (a < 0) { + a = -a; + b = '-'; + } + return b + leftZeroFill(toInt(a / 60), 2) + leftZeroFill(toInt(a) % 60, 2); + }, + z : function () { + return this.zoneAbbr(); + }, + zz : function () { + return this.zoneName(); + }, + x : function () { + return this.valueOf(); + }, + X : function () { + return this.unix(); + }, + Q : function () { + return this.quarter(); + } + }, + + deprecations = {}, + + lists = ['months', 'monthsShort', 'weekdays', 'weekdaysShort', 'weekdaysMin']; + + // Pick the first defined of two or three arguments. dfl comes from + // default. + function dfl(a, b, c) { + switch (arguments.length) { + case 2: return a != null ? a : b; + case 3: return a != null ? a : b != null ? b : c; + default: throw new Error('Implement me'); + } + } + + function hasOwnProp(a, b) { + return hasOwnProperty.call(a, b); + } + + function defaultParsingFlags() { + // We need to deep clone this object, and es5 standard is not very + // helpful. + return { + empty : false, + unusedTokens : [], + unusedInput : [], + overflow : -2, + charsLeftOver : 0, + nullInput : false, + invalidMonth : null, + invalidFormat : false, + userInvalidated : false, + iso: false + }; + } + + function printMsg(msg) { + if (moment.suppressDeprecationWarnings === false && + typeof console !== 'undefined' && console.warn) { + console.warn('Deprecation warning: ' + msg); + } + } + + function deprecate(msg, fn) { + var firstTime = true; + return extend(function () { + if (firstTime) { + printMsg(msg); + firstTime = false; + } + return fn.apply(this, arguments); + }, fn); + } + + function deprecateSimple(name, msg) { + if (!deprecations[name]) { + printMsg(msg); + deprecations[name] = true; + } + } + + function padToken(func, count) { + return function (a) { + return leftZeroFill(func.call(this, a), count); + }; + } + function ordinalizeToken(func, period) { + return function (a) { + return this.localeData().ordinal(func.call(this, a), period); + }; + } + + while (ordinalizeTokens.length) { + i = ordinalizeTokens.pop(); + formatTokenFunctions[i + 'o'] = ordinalizeToken(formatTokenFunctions[i], i); + } + while (paddedTokens.length) { + i = paddedTokens.pop(); + formatTokenFunctions[i + i] = padToken(formatTokenFunctions[i], 2); + } + formatTokenFunctions.DDDD = padToken(formatTokenFunctions.DDD, 3); + + + /************************************ + Constructors + ************************************/ + + function Locale() { + } + + // Moment prototype object + function Moment(config, skipOverflow) { + if (skipOverflow !== false) { + checkOverflow(config); + } + copyConfig(this, config); + this._d = new Date(+config._d); + } + + // Duration Constructor + function Duration(duration) { + var normalizedInput = normalizeObjectUnits(duration), + years = normalizedInput.year || 0, + quarters = normalizedInput.quarter || 0, + months = normalizedInput.month || 0, + weeks = normalizedInput.week || 0, + days = normalizedInput.day || 0, + hours = normalizedInput.hour || 0, + minutes = normalizedInput.minute || 0, + seconds = normalizedInput.second || 0, + milliseconds = normalizedInput.millisecond || 0; + + // representation for dateAddRemove + this._milliseconds = +milliseconds + + seconds * 1e3 + // 1000 + minutes * 6e4 + // 1000 * 60 + hours * 36e5; // 1000 * 60 * 60 + // Because of dateAddRemove treats 24 hours as different from a + // day when working around DST, we need to store them separately + this._days = +days + + weeks * 7; + // It is impossible translate months into days without knowing + // which months you are are talking about, so we have to store + // it separately. + this._months = +months + + quarters * 3 + + years * 12; + + this._data = {}; + + this._locale = moment.localeData(); + + this._bubble(); + } + + /************************************ + Helpers + ************************************/ + + + function extend(a, b) { + for (var i in b) { + if (hasOwnProp(b, i)) { + a[i] = b[i]; + } + } + + if (hasOwnProp(b, 'toString')) { + a.toString = b.toString; + } + + if (hasOwnProp(b, 'valueOf')) { + a.valueOf = b.valueOf; + } + + return a; + } + + function copyConfig(to, from) { + var i, prop, val; + + if (typeof from._isAMomentObject !== 'undefined') { + to._isAMomentObject = from._isAMomentObject; + } + if (typeof from._i !== 'undefined') { + to._i = from._i; + } + if (typeof from._f !== 'undefined') { + to._f = from._f; + } + if (typeof from._l !== 'undefined') { + to._l = from._l; + } + if (typeof from._strict !== 'undefined') { + to._strict = from._strict; + } + if (typeof from._tzm !== 'undefined') { + to._tzm = from._tzm; + } + if (typeof from._isUTC !== 'undefined') { + to._isUTC = from._isUTC; + } + if (typeof from._offset !== 'undefined') { + to._offset = from._offset; + } + if (typeof from._pf !== 'undefined') { + to._pf = from._pf; + } + if (typeof from._locale !== 'undefined') { + to._locale = from._locale; + } + + if (momentProperties.length > 0) { + for (i in momentProperties) { + prop = momentProperties[i]; + val = from[prop]; + if (typeof val !== 'undefined') { + to[prop] = val; + } + } + } + + return to; + } + + function absRound(number) { + if (number < 0) { + return Math.ceil(number); + } else { + return Math.floor(number); + } + } + + // left zero fill a number + // see http://jsperf.com/left-zero-filling for performance comparison + function leftZeroFill(number, targetLength, forceSign) { + var output = '' + Math.abs(number), + sign = number >= 0; + + while (output.length < targetLength) { + output = '0' + output; + } + return (sign ? (forceSign ? '+' : '') : '-') + output; + } + + function positiveMomentsDifference(base, other) { + var res = {milliseconds: 0, months: 0}; + + res.months = other.month() - base.month() + + (other.year() - base.year()) * 12; + if (base.clone().add(res.months, 'M').isAfter(other)) { + --res.months; + } + + res.milliseconds = +other - +(base.clone().add(res.months, 'M')); + + return res; + } + + function momentsDifference(base, other) { + var res; + other = makeAs(other, base); + if (base.isBefore(other)) { + res = positiveMomentsDifference(base, other); + } else { + res = positiveMomentsDifference(other, base); + res.milliseconds = -res.milliseconds; + res.months = -res.months; + } + + return res; + } + + // TODO: remove 'name' arg after deprecation is removed + function createAdder(direction, name) { + return function (val, period) { + var dur, tmp; + //invert the arguments, but complain about it + if (period !== null && !isNaN(+period)) { + deprecateSimple(name, 'moment().' + name + '(period, number) is deprecated. Please use moment().' + name + '(number, period).'); + tmp = val; val = period; period = tmp; + } + + val = typeof val === 'string' ? +val : val; + dur = moment.duration(val, period); + addOrSubtractDurationFromMoment(this, dur, direction); + return this; + }; + } + + function addOrSubtractDurationFromMoment(mom, duration, isAdding, updateOffset) { + var milliseconds = duration._milliseconds, + days = duration._days, + months = duration._months; + updateOffset = updateOffset == null ? true : updateOffset; + + if (milliseconds) { + mom._d.setTime(+mom._d + milliseconds * isAdding); + } + if (days) { + rawSetter(mom, 'Date', rawGetter(mom, 'Date') + days * isAdding); + } + if (months) { + rawMonthSetter(mom, rawGetter(mom, 'Month') + months * isAdding); + } + if (updateOffset) { + moment.updateOffset(mom, days || months); + } + } + + // check if is an array + function isArray(input) { + return Object.prototype.toString.call(input) === '[object Array]'; + } + + function isDate(input) { + return Object.prototype.toString.call(input) === '[object Date]' || + input instanceof Date; + } + + // compare two arrays, return the number of differences + function compareArrays(array1, array2, dontConvert) { + var len = Math.min(array1.length, array2.length), + lengthDiff = Math.abs(array1.length - array2.length), + diffs = 0, + i; + for (i = 0; i < len; i++) { + if ((dontConvert && array1[i] !== array2[i]) || + (!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) { + diffs++; + } + } + return diffs + lengthDiff; + } + + function normalizeUnits(units) { + if (units) { + var lowered = units.toLowerCase().replace(/(.)s$/, '$1'); + units = unitAliases[units] || camelFunctions[lowered] || lowered; + } + return units; + } + + function normalizeObjectUnits(inputObject) { + var normalizedInput = {}, + normalizedProp, + prop; + + for (prop in inputObject) { + if (hasOwnProp(inputObject, prop)) { + normalizedProp = normalizeUnits(prop); + if (normalizedProp) { + normalizedInput[normalizedProp] = inputObject[prop]; + } + } + } + + return normalizedInput; + } + + function makeList(field) { + var count, setter; + + if (field.indexOf('week') === 0) { + count = 7; + setter = 'day'; + } + else if (field.indexOf('month') === 0) { + count = 12; + setter = 'month'; + } + else { + return; + } + + moment[field] = function (format, index) { + var i, getter, + method = moment._locale[field], + results = []; + + if (typeof format === 'number') { + index = format; + format = undefined; + } + + getter = function (i) { + var m = moment().utc().set(setter, i); + return method.call(moment._locale, m, format || ''); + }; + + if (index != null) { + return getter(index); + } + else { + for (i = 0; i < count; i++) { + results.push(getter(i)); + } + return results; + } + }; + } + + function toInt(argumentForCoercion) { + var coercedNumber = +argumentForCoercion, + value = 0; + + if (coercedNumber !== 0 && isFinite(coercedNumber)) { + if (coercedNumber >= 0) { + value = Math.floor(coercedNumber); + } else { + value = Math.ceil(coercedNumber); + } + } + + return value; + } + + function daysInMonth(year, month) { + return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + } + + function weeksInYear(year, dow, doy) { + return weekOfYear(moment([year, 11, 31 + dow - doy]), dow, doy).week; + } + + function daysInYear(year) { + return isLeapYear(year) ? 366 : 365; + } + + function isLeapYear(year) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + } + + function checkOverflow(m) { + var overflow; + if (m._a && m._pf.overflow === -2) { + overflow = + m._a[MONTH] < 0 || m._a[MONTH] > 11 ? MONTH : + m._a[DATE] < 1 || m._a[DATE] > daysInMonth(m._a[YEAR], m._a[MONTH]) ? DATE : + m._a[HOUR] < 0 || m._a[HOUR] > 24 || + (m._a[HOUR] === 24 && (m._a[MINUTE] !== 0 || + m._a[SECOND] !== 0 || + m._a[MILLISECOND] !== 0)) ? HOUR : + m._a[MINUTE] < 0 || m._a[MINUTE] > 59 ? MINUTE : + m._a[SECOND] < 0 || m._a[SECOND] > 59 ? SECOND : + m._a[MILLISECOND] < 0 || m._a[MILLISECOND] > 999 ? MILLISECOND : + -1; + + if (m._pf._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) { + overflow = DATE; + } + + m._pf.overflow = overflow; + } + } + + function isValid(m) { + if (m._isValid == null) { + m._isValid = !isNaN(m._d.getTime()) && + m._pf.overflow < 0 && + !m._pf.empty && + !m._pf.invalidMonth && + !m._pf.nullInput && + !m._pf.invalidFormat && + !m._pf.userInvalidated; + + if (m._strict) { + m._isValid = m._isValid && + m._pf.charsLeftOver === 0 && + m._pf.unusedTokens.length === 0 && + m._pf.bigHour === undefined; + } + } + return m._isValid; + } + + function normalizeLocale(key) { + return key ? key.toLowerCase().replace('_', '-') : key; + } + + // pick the locale from the array + // try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each + // substring from most specific to least, but move to the next array item if it's a more specific variant than the current root + function chooseLocale(names) { + var i = 0, j, next, locale, split; + + while (i < names.length) { + split = normalizeLocale(names[i]).split('-'); + j = split.length; + next = normalizeLocale(names[i + 1]); + next = next ? next.split('-') : null; + while (j > 0) { + locale = loadLocale(split.slice(0, j).join('-')); + if (locale) { + return locale; + } + if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) { + //the next array item is better than a shallower substring of this one + break; + } + j--; + } + i++; + } + return null; + } + + function loadLocale(name) { + var oldLocale = null; + if (!locales[name] && hasModule) { + try { + oldLocale = moment.locale(); + require('./locale/' + name); + // because defineLocale currently also sets the global locale, we want to undo that for lazy loaded locales + moment.locale(oldLocale); + } catch (e) { } + } + return locales[name]; + } + + // Return a moment from input, that is local/utc/zone equivalent to model. + function makeAs(input, model) { + var res, diff; + if (model._isUTC) { + res = model.clone(); + diff = (moment.isMoment(input) || isDate(input) ? + +input : +moment(input)) - (+res); + // Use low-level api, because this fn is low-level api. + res._d.setTime(+res._d + diff); + moment.updateOffset(res, false); + return res; + } else { + return moment(input).local(); + } + } + + /************************************ + Locale + ************************************/ + + + extend(Locale.prototype, { + + set : function (config) { + var prop, i; + for (i in config) { + prop = config[i]; + if (typeof prop === 'function') { + this[i] = prop; + } else { + this['_' + i] = prop; + } + } + // Lenient ordinal parsing accepts just a number in addition to + // number + (possibly) stuff coming from _ordinalParseLenient. + this._ordinalParseLenient = new RegExp(this._ordinalParse.source + '|' + /\d{1,2}/.source); + }, + + _months : 'January_February_March_April_May_June_July_August_September_October_November_December'.split('_'), + months : function (m) { + return this._months[m.month()]; + }, + + _monthsShort : 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'), + monthsShort : function (m) { + return this._monthsShort[m.month()]; + }, + + monthsParse : function (monthName, format, strict) { + var i, mom, regex; + + if (!this._monthsParse) { + this._monthsParse = []; + this._longMonthsParse = []; + this._shortMonthsParse = []; + } + + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = moment.utc([2000, i]); + if (strict && !this._longMonthsParse[i]) { + this._longMonthsParse[i] = new RegExp('^' + this.months(mom, '').replace('.', '') + '$', 'i'); + this._shortMonthsParse[i] = new RegExp('^' + this.monthsShort(mom, '').replace('.', '') + '$', 'i'); + } + if (!strict && !this._monthsParse[i]) { + regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); + this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if (strict && format === 'MMMM' && this._longMonthsParse[i].test(monthName)) { + return i; + } else if (strict && format === 'MMM' && this._shortMonthsParse[i].test(monthName)) { + return i; + } else if (!strict && this._monthsParse[i].test(monthName)) { + return i; + } + } + }, + + _weekdays : 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'), + weekdays : function (m) { + return this._weekdays[m.day()]; + }, + + _weekdaysShort : 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'), + weekdaysShort : function (m) { + return this._weekdaysShort[m.day()]; + }, + + _weekdaysMin : 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'), + weekdaysMin : function (m) { + return this._weekdaysMin[m.day()]; + }, + + weekdaysParse : function (weekdayName) { + var i, mom, regex; + + if (!this._weekdaysParse) { + this._weekdaysParse = []; + } + + for (i = 0; i < 7; i++) { + // make the regex if we don't have it already + if (!this._weekdaysParse[i]) { + mom = moment([2000, 1]).day(i); + regex = '^' + this.weekdays(mom, '') + '|^' + this.weekdaysShort(mom, '') + '|^' + this.weekdaysMin(mom, ''); + this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if (this._weekdaysParse[i].test(weekdayName)) { + return i; + } + } + }, + + _longDateFormat : { + LTS : 'h:mm:ss A', + LT : 'h:mm A', + L : 'MM/DD/YYYY', + LL : 'MMMM D, YYYY', + LLL : 'MMMM D, YYYY LT', + LLLL : 'dddd, MMMM D, YYYY LT' + }, + longDateFormat : function (key) { + var output = this._longDateFormat[key]; + if (!output && this._longDateFormat[key.toUpperCase()]) { + output = this._longDateFormat[key.toUpperCase()].replace(/MMMM|MM|DD|dddd/g, function (val) { + return val.slice(1); + }); + this._longDateFormat[key] = output; + } + return output; + }, + + isPM : function (input) { + // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays + // Using charAt should be more compatible. + return ((input + '').toLowerCase().charAt(0) === 'p'); + }, + + _meridiemParse : /[ap]\.?m?\.?/i, + meridiem : function (hours, minutes, isLower) { + if (hours > 11) { + return isLower ? 'pm' : 'PM'; + } else { + return isLower ? 'am' : 'AM'; + } + }, + + _calendar : { + sameDay : '[Today at] LT', + nextDay : '[Tomorrow at] LT', + nextWeek : 'dddd [at] LT', + lastDay : '[Yesterday at] LT', + lastWeek : '[Last] dddd [at] LT', + sameElse : 'L' + }, + calendar : function (key, mom, now) { + var output = this._calendar[key]; + return typeof output === 'function' ? output.apply(mom, [now]) : output; + }, + + _relativeTime : { + future : 'in %s', + past : '%s ago', + s : 'a few seconds', + m : 'a minute', + mm : '%d minutes', + h : 'an hour', + hh : '%d hours', + d : 'a day', + dd : '%d days', + M : 'a month', + MM : '%d months', + y : 'a year', + yy : '%d years' + }, + + relativeTime : function (number, withoutSuffix, string, isFuture) { + var output = this._relativeTime[string]; + return (typeof output === 'function') ? + output(number, withoutSuffix, string, isFuture) : + output.replace(/%d/i, number); + }, + + pastFuture : function (diff, output) { + var format = this._relativeTime[diff > 0 ? 'future' : 'past']; + return typeof format === 'function' ? format(output) : format.replace(/%s/i, output); + }, + + ordinal : function (number) { + return this._ordinal.replace('%d', number); + }, + _ordinal : '%d', + _ordinalParse : /\d{1,2}/, + + preparse : function (string) { + return string; + }, + + postformat : function (string) { + return string; + }, + + week : function (mom) { + return weekOfYear(mom, this._week.dow, this._week.doy).week; + }, + + _week : { + dow : 0, // Sunday is the first day of the week. + doy : 6 // The week that contains Jan 1st is the first week of the year. + }, + + _invalidDate: 'Invalid date', + invalidDate: function () { + return this._invalidDate; + } + }); + + /************************************ + Formatting + ************************************/ + + + function removeFormattingTokens(input) { + if (input.match(/\[[\s\S]/)) { + return input.replace(/^\[|\]$/g, ''); + } + return input.replace(/\\/g, ''); + } + + function makeFormatFunction(format) { + var array = format.match(formattingTokens), i, length; + + for (i = 0, length = array.length; i < length; i++) { + if (formatTokenFunctions[array[i]]) { + array[i] = formatTokenFunctions[array[i]]; + } else { + array[i] = removeFormattingTokens(array[i]); + } + } + + return function (mom) { + var output = ''; + for (i = 0; i < length; i++) { + output += array[i] instanceof Function ? array[i].call(mom, format) : array[i]; + } + return output; + }; + } + + // format date using native date object + function formatMoment(m, format) { + if (!m.isValid()) { + return m.localeData().invalidDate(); + } + + format = expandFormat(format, m.localeData()); + + if (!formatFunctions[format]) { + formatFunctions[format] = makeFormatFunction(format); + } + + return formatFunctions[format](m); + } + + function expandFormat(format, locale) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return locale.longDateFormat(input) || input; + } + + localFormattingTokens.lastIndex = 0; + while (i >= 0 && localFormattingTokens.test(format)) { + format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); + localFormattingTokens.lastIndex = 0; + i -= 1; + } + + return format; + } + + + /************************************ + Parsing + ************************************/ + + + // get the regex to find the next token + function getParseRegexForToken(token, config) { + var a, strict = config._strict; + switch (token) { + case 'Q': + return parseTokenOneDigit; + case 'DDDD': + return parseTokenThreeDigits; + case 'YYYY': + case 'GGGG': + case 'gggg': + return strict ? parseTokenFourDigits : parseTokenOneToFourDigits; + case 'Y': + case 'G': + case 'g': + return parseTokenSignedNumber; + case 'YYYYYY': + case 'YYYYY': + case 'GGGGG': + case 'ggggg': + return strict ? parseTokenSixDigits : parseTokenOneToSixDigits; + case 'S': + if (strict) { + return parseTokenOneDigit; + } + /* falls through */ + case 'SS': + if (strict) { + return parseTokenTwoDigits; + } + /* falls through */ + case 'SSS': + if (strict) { + return parseTokenThreeDigits; + } + /* falls through */ + case 'DDD': + return parseTokenOneToThreeDigits; + case 'MMM': + case 'MMMM': + case 'dd': + case 'ddd': + case 'dddd': + return parseTokenWord; + case 'a': + case 'A': + return config._locale._meridiemParse; + case 'x': + return parseTokenOffsetMs; + case 'X': + return parseTokenTimestampMs; + case 'Z': + case 'ZZ': + return parseTokenTimezone; + case 'T': + return parseTokenT; + case 'SSSS': + return parseTokenDigits; + case 'MM': + case 'DD': + case 'YY': + case 'GG': + case 'gg': + case 'HH': + case 'hh': + case 'mm': + case 'ss': + case 'ww': + case 'WW': + return strict ? parseTokenTwoDigits : parseTokenOneOrTwoDigits; + case 'M': + case 'D': + case 'd': + case 'H': + case 'h': + case 'm': + case 's': + case 'w': + case 'W': + case 'e': + case 'E': + return parseTokenOneOrTwoDigits; + case 'Do': + return strict ? config._locale._ordinalParse : config._locale._ordinalParseLenient; + default : + a = new RegExp(regexpEscape(unescapeFormat(token.replace('\\', '')), 'i')); + return a; + } + } + + function timezoneMinutesFromString(string) { + string = string || ''; + var possibleTzMatches = (string.match(parseTokenTimezone) || []), + tzChunk = possibleTzMatches[possibleTzMatches.length - 1] || [], + parts = (tzChunk + '').match(parseTimezoneChunker) || ['-', 0, 0], + minutes = +(parts[1] * 60) + toInt(parts[2]); + + return parts[0] === '+' ? -minutes : minutes; + } + + // function to convert string input to date + function addTimeToArrayFromToken(token, input, config) { + var a, datePartArray = config._a; + + switch (token) { + // QUARTER + case 'Q': + if (input != null) { + datePartArray[MONTH] = (toInt(input) - 1) * 3; + } + break; + // MONTH + case 'M' : // fall through to MM + case 'MM' : + if (input != null) { + datePartArray[MONTH] = toInt(input) - 1; + } + break; + case 'MMM' : // fall through to MMMM + case 'MMMM' : + a = config._locale.monthsParse(input, token, config._strict); + // if we didn't find a month name, mark the date as invalid. + if (a != null) { + datePartArray[MONTH] = a; + } else { + config._pf.invalidMonth = input; + } + break; + // DAY OF MONTH + case 'D' : // fall through to DD + case 'DD' : + if (input != null) { + datePartArray[DATE] = toInt(input); + } + break; + case 'Do' : + if (input != null) { + datePartArray[DATE] = toInt(parseInt( + input.match(/\d{1,2}/)[0], 10)); + } + break; + // DAY OF YEAR + case 'DDD' : // fall through to DDDD + case 'DDDD' : + if (input != null) { + config._dayOfYear = toInt(input); + } + + break; + // YEAR + case 'YY' : + datePartArray[YEAR] = moment.parseTwoDigitYear(input); + break; + case 'YYYY' : + case 'YYYYY' : + case 'YYYYYY' : + datePartArray[YEAR] = toInt(input); + break; + // AM / PM + case 'a' : // fall through to A + case 'A' : + config._isPm = config._locale.isPM(input); + break; + // HOUR + case 'h' : // fall through to hh + case 'hh' : + config._pf.bigHour = true; + /* falls through */ + case 'H' : // fall through to HH + case 'HH' : + datePartArray[HOUR] = toInt(input); + break; + // MINUTE + case 'm' : // fall through to mm + case 'mm' : + datePartArray[MINUTE] = toInt(input); + break; + // SECOND + case 's' : // fall through to ss + case 'ss' : + datePartArray[SECOND] = toInt(input); + break; + // MILLISECOND + case 'S' : + case 'SS' : + case 'SSS' : + case 'SSSS' : + datePartArray[MILLISECOND] = toInt(('0.' + input) * 1000); + break; + // UNIX OFFSET (MILLISECONDS) + case 'x': + config._d = new Date(toInt(input)); + break; + // UNIX TIMESTAMP WITH MS + case 'X': + config._d = new Date(parseFloat(input) * 1000); + break; + // TIMEZONE + case 'Z' : // fall through to ZZ + case 'ZZ' : + config._useUTC = true; + config._tzm = timezoneMinutesFromString(input); + break; + // WEEKDAY - human + case 'dd': + case 'ddd': + case 'dddd': + a = config._locale.weekdaysParse(input); + // if we didn't get a weekday name, mark the date as invalid + if (a != null) { + config._w = config._w || {}; + config._w['d'] = a; + } else { + config._pf.invalidWeekday = input; + } + break; + // WEEK, WEEK DAY - numeric + case 'w': + case 'ww': + case 'W': + case 'WW': + case 'd': + case 'e': + case 'E': + token = token.substr(0, 1); + /* falls through */ + case 'gggg': + case 'GGGG': + case 'GGGGG': + token = token.substr(0, 2); + if (input) { + config._w = config._w || {}; + config._w[token] = toInt(input); + } + break; + case 'gg': + case 'GG': + config._w = config._w || {}; + config._w[token] = moment.parseTwoDigitYear(input); + } + } + + function dayOfYearFromWeekInfo(config) { + var w, weekYear, week, weekday, dow, doy, temp; + + w = config._w; + if (w.GG != null || w.W != null || w.E != null) { + dow = 1; + doy = 4; + + // TODO: We need to take the current isoWeekYear, but that depends on + // how we interpret now (local, utc, fixed offset). So create + // a now version of current config (take local/utc/offset flags, and + // create now). + weekYear = dfl(w.GG, config._a[YEAR], weekOfYear(moment(), 1, 4).year); + week = dfl(w.W, 1); + weekday = dfl(w.E, 1); + } else { + dow = config._locale._week.dow; + doy = config._locale._week.doy; + + weekYear = dfl(w.gg, config._a[YEAR], weekOfYear(moment(), dow, doy).year); + week = dfl(w.w, 1); + + if (w.d != null) { + // weekday -- low day numbers are considered next week + weekday = w.d; + if (weekday < dow) { + ++week; + } + } else if (w.e != null) { + // local weekday -- counting starts from begining of week + weekday = w.e + dow; + } else { + // default to begining of week + weekday = dow; + } + } + temp = dayOfYearFromWeeks(weekYear, week, weekday, doy, dow); + + config._a[YEAR] = temp.year; + config._dayOfYear = temp.dayOfYear; + } + + // convert an array to a date. + // the array should mirror the parameters below + // note: all values past the year are optional and will default to the lowest possible value. + // [year, month, day , hour, minute, second, millisecond] + function dateFromConfig(config) { + var i, date, input = [], currentDate, yearToUse; + + if (config._d) { + return; + } + + currentDate = currentDateArray(config); + + //compute day of the year from weeks and weekdays + if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { + dayOfYearFromWeekInfo(config); + } + + //if the day of the year is set, figure out what it is + if (config._dayOfYear) { + yearToUse = dfl(config._a[YEAR], currentDate[YEAR]); + + if (config._dayOfYear > daysInYear(yearToUse)) { + config._pf._overflowDayOfYear = true; + } + + date = makeUTCDate(yearToUse, 0, config._dayOfYear); + config._a[MONTH] = date.getUTCMonth(); + config._a[DATE] = date.getUTCDate(); + } + + // Default to current date. + // * if no year, month, day of month are given, default to today + // * if day of month is given, default month and year + // * if month is given, default only year + // * if year is given, don't default anything + for (i = 0; i < 3 && config._a[i] == null; ++i) { + config._a[i] = input[i] = currentDate[i]; + } + + // Zero out whatever was not defaulted, including time + for (; i < 7; i++) { + config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i]; + } + + // Check for 24:00:00.000 + if (config._a[HOUR] === 24 && + config._a[MINUTE] === 0 && + config._a[SECOND] === 0 && + config._a[MILLISECOND] === 0) { + config._nextDay = true; + config._a[HOUR] = 0; + } + + config._d = (config._useUTC ? makeUTCDate : makeDate).apply(null, input); + // Apply timezone offset from input. The actual zone can be changed + // with parseZone. + if (config._tzm != null) { + config._d.setUTCMinutes(config._d.getUTCMinutes() + config._tzm); + } + + if (config._nextDay) { + config._a[HOUR] = 24; + } + } + + function dateFromObject(config) { + var normalizedInput; + + if (config._d) { + return; + } + + normalizedInput = normalizeObjectUnits(config._i); + config._a = [ + normalizedInput.year, + normalizedInput.month, + normalizedInput.day || normalizedInput.date, + normalizedInput.hour, + normalizedInput.minute, + normalizedInput.second, + normalizedInput.millisecond + ]; + + dateFromConfig(config); + } + + function currentDateArray(config) { + var now = new Date(); + if (config._useUTC) { + return [ + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + ]; + } else { + return [now.getFullYear(), now.getMonth(), now.getDate()]; + } + } + + // date from string and format string + function makeDateFromStringAndFormat(config) { + if (config._f === moment.ISO_8601) { + parseISO(config); + return; + } + + config._a = []; + config._pf.empty = true; + + // This array is used to make a Date, either with `new Date` or `Date.UTC` + var string = '' + config._i, + i, parsedInput, tokens, token, skipped, + stringLength = string.length, + totalParsedInputLength = 0; + + tokens = expandFormat(config._f, config._locale).match(formattingTokens) || []; + + for (i = 0; i < tokens.length; i++) { + token = tokens[i]; + parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0]; + if (parsedInput) { + skipped = string.substr(0, string.indexOf(parsedInput)); + if (skipped.length > 0) { + config._pf.unusedInput.push(skipped); + } + string = string.slice(string.indexOf(parsedInput) + parsedInput.length); + totalParsedInputLength += parsedInput.length; + } + // don't parse if it's not a known token + if (formatTokenFunctions[token]) { + if (parsedInput) { + config._pf.empty = false; + } + else { + config._pf.unusedTokens.push(token); + } + addTimeToArrayFromToken(token, parsedInput, config); + } + else if (config._strict && !parsedInput) { + config._pf.unusedTokens.push(token); + } + } + + // add remaining unparsed input length to the string + config._pf.charsLeftOver = stringLength - totalParsedInputLength; + if (string.length > 0) { + config._pf.unusedInput.push(string); + } + + // clear _12h flag if hour is <= 12 + if (config._pf.bigHour === true && config._a[HOUR] <= 12) { + config._pf.bigHour = undefined; + } + // handle am pm + if (config._isPm && config._a[HOUR] < 12) { + config._a[HOUR] += 12; + } + // if is 12 am, change hours to 0 + if (config._isPm === false && config._a[HOUR] === 12) { + config._a[HOUR] = 0; + } + dateFromConfig(config); + checkOverflow(config); + } + + function unescapeFormat(s) { + return s.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) { + return p1 || p2 || p3 || p4; + }); + } + + // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript + function regexpEscape(s) { + return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + } + + // date from string and array of format strings + function makeDateFromStringAndArray(config) { + var tempConfig, + bestMoment, + + scoreToBeat, + i, + currentScore; + + if (config._f.length === 0) { + config._pf.invalidFormat = true; + config._d = new Date(NaN); + return; + } + + for (i = 0; i < config._f.length; i++) { + currentScore = 0; + tempConfig = copyConfig({}, config); + if (config._useUTC != null) { + tempConfig._useUTC = config._useUTC; + } + tempConfig._pf = defaultParsingFlags(); + tempConfig._f = config._f[i]; + makeDateFromStringAndFormat(tempConfig); + + if (!isValid(tempConfig)) { + continue; + } + + // if there is any input that was not parsed add a penalty for that format + currentScore += tempConfig._pf.charsLeftOver; + + //or tokens + currentScore += tempConfig._pf.unusedTokens.length * 10; + + tempConfig._pf.score = currentScore; + + if (scoreToBeat == null || currentScore < scoreToBeat) { + scoreToBeat = currentScore; + bestMoment = tempConfig; + } + } + + extend(config, bestMoment || tempConfig); + } + + // date from iso format + function parseISO(config) { + var i, l, + string = config._i, + match = isoRegex.exec(string); + + if (match) { + config._pf.iso = true; + for (i = 0, l = isoDates.length; i < l; i++) { + if (isoDates[i][1].exec(string)) { + // match[5] should be 'T' or undefined + config._f = isoDates[i][0] + (match[6] || ' '); + break; + } + } + for (i = 0, l = isoTimes.length; i < l; i++) { + if (isoTimes[i][1].exec(string)) { + config._f += isoTimes[i][0]; + break; + } + } + if (string.match(parseTokenTimezone)) { + config._f += 'Z'; + } + makeDateFromStringAndFormat(config); + } else { + config._isValid = false; + } + } + + // date from iso format or fallback + function makeDateFromString(config) { + parseISO(config); + if (config._isValid === false) { + delete config._isValid; + moment.createFromInputFallback(config); + } + } + + function map(arr, fn) { + var res = [], i; + for (i = 0; i < arr.length; ++i) { + res.push(fn(arr[i], i)); + } + return res; + } + + function makeDateFromInput(config) { + var input = config._i, matched; + if (input === undefined) { + config._d = new Date(); + } else if (isDate(input)) { + config._d = new Date(+input); + } else if ((matched = aspNetJsonRegex.exec(input)) !== null) { + config._d = new Date(+matched[1]); + } else if (typeof input === 'string') { + makeDateFromString(config); + } else if (isArray(input)) { + config._a = map(input.slice(0), function (obj) { + return parseInt(obj, 10); + }); + dateFromConfig(config); + } else if (typeof(input) === 'object') { + dateFromObject(config); + } else if (typeof(input) === 'number') { + // from milliseconds + config._d = new Date(input); + } else { + moment.createFromInputFallback(config); + } + } + + function makeDate(y, m, d, h, M, s, ms) { + //can't just apply() to create a date: + //http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply + var date = new Date(y, m, d, h, M, s, ms); + + //the date constructor doesn't accept years < 1970 + if (y < 1970) { + date.setFullYear(y); + } + return date; + } + + function makeUTCDate(y) { + var date = new Date(Date.UTC.apply(null, arguments)); + if (y < 1970) { + date.setUTCFullYear(y); + } + return date; + } + + function parseWeekday(input, locale) { + if (typeof input === 'string') { + if (!isNaN(input)) { + input = parseInt(input, 10); + } + else { + input = locale.weekdaysParse(input); + if (typeof input !== 'number') { + return null; + } + } + } + return input; + } + + /************************************ + Relative Time + ************************************/ + + + // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize + function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) { + return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture); + } + + function relativeTime(posNegDuration, withoutSuffix, locale) { + var duration = moment.duration(posNegDuration).abs(), + seconds = round(duration.as('s')), + minutes = round(duration.as('m')), + hours = round(duration.as('h')), + days = round(duration.as('d')), + months = round(duration.as('M')), + years = round(duration.as('y')), + + args = seconds < relativeTimeThresholds.s && ['s', seconds] || + minutes === 1 && ['m'] || + minutes < relativeTimeThresholds.m && ['mm', minutes] || + hours === 1 && ['h'] || + hours < relativeTimeThresholds.h && ['hh', hours] || + days === 1 && ['d'] || + days < relativeTimeThresholds.d && ['dd', days] || + months === 1 && ['M'] || + months < relativeTimeThresholds.M && ['MM', months] || + years === 1 && ['y'] || ['yy', years]; + + args[2] = withoutSuffix; + args[3] = +posNegDuration > 0; + args[4] = locale; + return substituteTimeAgo.apply({}, args); + } + + + /************************************ + Week of Year + ************************************/ + + + // firstDayOfWeek 0 = sun, 6 = sat + // the day of the week that starts the week + // (usually sunday or monday) + // firstDayOfWeekOfYear 0 = sun, 6 = sat + // the first week is the week that contains the first + // of this day of the week + // (eg. ISO weeks use thursday (4)) + function weekOfYear(mom, firstDayOfWeek, firstDayOfWeekOfYear) { + var end = firstDayOfWeekOfYear - firstDayOfWeek, + daysToDayOfWeek = firstDayOfWeekOfYear - mom.day(), + adjustedMoment; + + + if (daysToDayOfWeek > end) { + daysToDayOfWeek -= 7; + } + + if (daysToDayOfWeek < end - 7) { + daysToDayOfWeek += 7; + } + + adjustedMoment = moment(mom).add(daysToDayOfWeek, 'd'); + return { + week: Math.ceil(adjustedMoment.dayOfYear() / 7), + year: adjustedMoment.year() + }; + } + + //http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday + function dayOfYearFromWeeks(year, week, weekday, firstDayOfWeekOfYear, firstDayOfWeek) { + var d = makeUTCDate(year, 0, 1).getUTCDay(), daysToAdd, dayOfYear; + + d = d === 0 ? 7 : d; + weekday = weekday != null ? weekday : firstDayOfWeek; + daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0) - (d < firstDayOfWeek ? 7 : 0); + dayOfYear = 7 * (week - 1) + (weekday - firstDayOfWeek) + daysToAdd + 1; + + return { + year: dayOfYear > 0 ? year : year - 1, + dayOfYear: dayOfYear > 0 ? dayOfYear : daysInYear(year - 1) + dayOfYear + }; + } + + /************************************ + Top Level Functions + ************************************/ + + function makeMoment(config) { + var input = config._i, + format = config._f, + res; + + config._locale = config._locale || moment.localeData(config._l); + + if (input === null || (format === undefined && input === '')) { + return moment.invalid({nullInput: true}); + } + + if (typeof input === 'string') { + config._i = input = config._locale.preparse(input); + } + + if (moment.isMoment(input)) { + return new Moment(input, true); + } else if (format) { + if (isArray(format)) { + makeDateFromStringAndArray(config); + } else { + makeDateFromStringAndFormat(config); + } + } else { + makeDateFromInput(config); + } + + res = new Moment(config); + if (res._nextDay) { + // Adding is smart enough around DST + res.add(1, 'd'); + res._nextDay = undefined; + } + + return res; + } + + moment = function (input, format, locale, strict) { + var c; + + if (typeof(locale) === 'boolean') { + strict = locale; + locale = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c = {}; + c._isAMomentObject = true; + c._i = input; + c._f = format; + c._l = locale; + c._strict = strict; + c._isUTC = false; + c._pf = defaultParsingFlags(); + + return makeMoment(c); + }; + + moment.suppressDeprecationWarnings = false; + + moment.createFromInputFallback = deprecate( + 'moment construction falls back to js Date. This is ' + + 'discouraged and will be removed in upcoming major ' + + 'release. Please refer to ' + + 'https://github.com/moment/moment/issues/1407 for more info.', + function (config) { + config._d = new Date(config._i + (config._useUTC ? ' UTC' : '')); + } + ); + + // Pick a moment m from moments so that m[fn](other) is true for all + // other. This relies on the function fn to be transitive. + // + // moments should either be an array of moment objects or an array, whose + // first element is an array of moment objects. + function pickBy(fn, moments) { + var res, i; + if (moments.length === 1 && isArray(moments[0])) { + moments = moments[0]; + } + if (!moments.length) { + return moment(); + } + res = moments[0]; + for (i = 1; i < moments.length; ++i) { + if (moments[i][fn](res)) { + res = moments[i]; + } + } + return res; + } + + moment.min = function () { + var args = [].slice.call(arguments, 0); + + return pickBy('isBefore', args); + }; + + moment.max = function () { + var args = [].slice.call(arguments, 0); + + return pickBy('isAfter', args); + }; + + // creating with utc + moment.utc = function (input, format, locale, strict) { + var c; + + if (typeof(locale) === 'boolean') { + strict = locale; + locale = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c = {}; + c._isAMomentObject = true; + c._useUTC = true; + c._isUTC = true; + c._l = locale; + c._i = input; + c._f = format; + c._strict = strict; + c._pf = defaultParsingFlags(); + + return makeMoment(c).utc(); + }; + + // creating with unix timestamp (in seconds) + moment.unix = function (input) { + return moment(input * 1000); + }; + + // duration + moment.duration = function (input, key) { + var duration = input, + // matching against regexp is expensive, do it on demand + match = null, + sign, + ret, + parseIso, + diffRes; + + if (moment.isDuration(input)) { + duration = { + ms: input._milliseconds, + d: input._days, + M: input._months + }; + } else if (typeof input === 'number') { + duration = {}; + if (key) { + duration[key] = input; + } else { + duration.milliseconds = input; + } + } else if (!!(match = aspNetTimeSpanJsonRegex.exec(input))) { + sign = (match[1] === '-') ? -1 : 1; + duration = { + y: 0, + d: toInt(match[DATE]) * sign, + h: toInt(match[HOUR]) * sign, + m: toInt(match[MINUTE]) * sign, + s: toInt(match[SECOND]) * sign, + ms: toInt(match[MILLISECOND]) * sign + }; + } else if (!!(match = isoDurationRegex.exec(input))) { + sign = (match[1] === '-') ? -1 : 1; + parseIso = function (inp) { + // We'd normally use ~~inp for this, but unfortunately it also + // converts floats to ints. + // inp may be undefined, so careful calling replace on it. + var res = inp && parseFloat(inp.replace(',', '.')); + // apply sign while we're at it + return (isNaN(res) ? 0 : res) * sign; + }; + duration = { + y: parseIso(match[2]), + M: parseIso(match[3]), + d: parseIso(match[4]), + h: parseIso(match[5]), + m: parseIso(match[6]), + s: parseIso(match[7]), + w: parseIso(match[8]) + }; + } else if (typeof duration === 'object' && + ('from' in duration || 'to' in duration)) { + diffRes = momentsDifference(moment(duration.from), moment(duration.to)); + + duration = {}; + duration.ms = diffRes.milliseconds; + duration.M = diffRes.months; + } + + ret = new Duration(duration); + + if (moment.isDuration(input) && hasOwnProp(input, '_locale')) { + ret._locale = input._locale; + } + + return ret; + }; + + // version number + moment.version = VERSION; + + // default format + moment.defaultFormat = isoFormat; + + // constant that refers to the ISO standard + moment.ISO_8601 = function () {}; + + // Plugins that add properties should also add the key here (null value), + // so we can properly clone ourselves. + moment.momentProperties = momentProperties; + + // This function will be called whenever a moment is mutated. + // It is intended to keep the offset in sync with the timezone. + moment.updateOffset = function () {}; + + // This function allows you to set a threshold for relative time strings + moment.relativeTimeThreshold = function (threshold, limit) { + if (relativeTimeThresholds[threshold] === undefined) { + return false; + } + if (limit === undefined) { + return relativeTimeThresholds[threshold]; + } + relativeTimeThresholds[threshold] = limit; + return true; + }; + + moment.lang = deprecate( + 'moment.lang is deprecated. Use moment.locale instead.', + function (key, value) { + return moment.locale(key, value); + } + ); + + // This function will load locale and then set the global locale. If + // no arguments are passed in, it will simply return the current global + // locale key. + moment.locale = function (key, values) { + var data; + if (key) { + if (typeof(values) !== 'undefined') { + data = moment.defineLocale(key, values); + } + else { + data = moment.localeData(key); + } + + if (data) { + moment.duration._locale = moment._locale = data; + } + } + + return moment._locale._abbr; + }; + + moment.defineLocale = function (name, values) { + if (values !== null) { + values.abbr = name; + if (!locales[name]) { + locales[name] = new Locale(); + } + locales[name].set(values); + + // backwards compat for now: also set the locale + moment.locale(name); + + return locales[name]; + } else { + // useful for testing + delete locales[name]; + return null; + } + }; + + moment.langData = deprecate( + 'moment.langData is deprecated. Use moment.localeData instead.', + function (key) { + return moment.localeData(key); + } + ); + + // returns locale data + moment.localeData = function (key) { + var locale; + + if (key && key._locale && key._locale._abbr) { + key = key._locale._abbr; + } + + if (!key) { + return moment._locale; + } + + if (!isArray(key)) { + //short-circuit everything else + locale = loadLocale(key); + if (locale) { + return locale; + } + key = [key]; + } + + return chooseLocale(key); + }; + + // compare moment object + moment.isMoment = function (obj) { + return obj instanceof Moment || + (obj != null && hasOwnProp(obj, '_isAMomentObject')); + }; + + // for typechecking Duration objects + moment.isDuration = function (obj) { + return obj instanceof Duration; + }; + + for (i = lists.length - 1; i >= 0; --i) { + makeList(lists[i]); + } + + moment.normalizeUnits = function (units) { + return normalizeUnits(units); + }; + + moment.invalid = function (flags) { + var m = moment.utc(NaN); + if (flags != null) { + extend(m._pf, flags); + } + else { + m._pf.userInvalidated = true; + } + + return m; + }; + + moment.parseZone = function () { + return moment.apply(null, arguments).parseZone(); + }; + + moment.parseTwoDigitYear = function (input) { + return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); + }; + + /************************************ + Moment Prototype + ************************************/ + + + extend(moment.fn = Moment.prototype, { + + clone : function () { + return moment(this); + }, + + valueOf : function () { + return +this._d + ((this._offset || 0) * 60000); + }, + + unix : function () { + return Math.floor(+this / 1000); + }, + + toString : function () { + return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); + }, + + toDate : function () { + return this._offset ? new Date(+this) : this._d; + }, + + toISOString : function () { + var m = moment(this).utc(); + if (0 < m.year() && m.year() <= 9999) { + if ('function' === typeof Date.prototype.toISOString) { + // native implementation is ~50x faster, use it when we can + return this.toDate().toISOString(); + } else { + return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + } + } else { + return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + } + }, + + toArray : function () { + var m = this; + return [ + m.year(), + m.month(), + m.date(), + m.hours(), + m.minutes(), + m.seconds(), + m.milliseconds() + ]; + }, + + isValid : function () { + return isValid(this); + }, + + isDSTShifted : function () { + if (this._a) { + return this.isValid() && compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()) > 0; + } + + return false; + }, + + parsingFlags : function () { + return extend({}, this._pf); + }, + + invalidAt: function () { + return this._pf.overflow; + }, + + utc : function (keepLocalTime) { + return this.zone(0, keepLocalTime); + }, + + local : function (keepLocalTime) { + if (this._isUTC) { + this.zone(0, keepLocalTime); + this._isUTC = false; + + if (keepLocalTime) { + this.add(this._dateTzOffset(), 'm'); + } + } + return this; + }, + + format : function (inputString) { + var output = formatMoment(this, inputString || moment.defaultFormat); + return this.localeData().postformat(output); + }, + + add : createAdder(1, 'add'), + + subtract : createAdder(-1, 'subtract'), + + diff : function (input, units, asFloat) { + var that = makeAs(input, this), + zoneDiff = (this.zone() - that.zone()) * 6e4, + diff, output, daysAdjust; + + units = normalizeUnits(units); + + if (units === 'year' || units === 'month') { + // average number of days in the months in the given dates + diff = (this.daysInMonth() + that.daysInMonth()) * 432e5; // 24 * 60 * 60 * 1000 / 2 + // difference in months + output = ((this.year() - that.year()) * 12) + (this.month() - that.month()); + // adjust by taking difference in days, average number of days + // and dst in the given months. + daysAdjust = (this - moment(this).startOf('month')) - + (that - moment(that).startOf('month')); + // same as above but with zones, to negate all dst + daysAdjust -= ((this.zone() - moment(this).startOf('month').zone()) - + (that.zone() - moment(that).startOf('month').zone())) * 6e4; + output += daysAdjust / diff; + if (units === 'year') { + output = output / 12; + } + } else { + diff = (this - that); + output = units === 'second' ? diff / 1e3 : // 1000 + units === 'minute' ? diff / 6e4 : // 1000 * 60 + units === 'hour' ? diff / 36e5 : // 1000 * 60 * 60 + units === 'day' ? (diff - zoneDiff) / 864e5 : // 1000 * 60 * 60 * 24, negate dst + units === 'week' ? (diff - zoneDiff) / 6048e5 : // 1000 * 60 * 60 * 24 * 7, negate dst + diff; + } + return asFloat ? output : absRound(output); + }, + + from : function (time, withoutSuffix) { + return moment.duration({to: this, from: time}).locale(this.locale()).humanize(!withoutSuffix); + }, + + fromNow : function (withoutSuffix) { + return this.from(moment(), withoutSuffix); + }, + + calendar : function (time) { + // We want to compare the start of today, vs this. + // Getting start-of-today depends on whether we're zone'd or not. + var now = time || moment(), + sod = makeAs(now, this).startOf('day'), + diff = this.diff(sod, 'days', true), + format = diff < -6 ? 'sameElse' : + diff < -1 ? 'lastWeek' : + diff < 0 ? 'lastDay' : + diff < 1 ? 'sameDay' : + diff < 2 ? 'nextDay' : + diff < 7 ? 'nextWeek' : 'sameElse'; + return this.format(this.localeData().calendar(format, this, moment(now))); + }, + + isLeapYear : function () { + return isLeapYear(this.year()); + }, + + isDST : function () { + return (this.zone() < this.clone().month(0).zone() || + this.zone() < this.clone().month(5).zone()); + }, + + day : function (input) { + var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); + if (input != null) { + input = parseWeekday(input, this.localeData()); + return this.add(input - day, 'd'); + } else { + return day; + } + }, + + month : makeAccessor('Month', true), + + startOf : function (units) { + units = normalizeUnits(units); + // the following switch intentionally omits break keywords + // to utilize falling through the cases. + switch (units) { + case 'year': + this.month(0); + /* falls through */ + case 'quarter': + case 'month': + this.date(1); + /* falls through */ + case 'week': + case 'isoWeek': + case 'day': + this.hours(0); + /* falls through */ + case 'hour': + this.minutes(0); + /* falls through */ + case 'minute': + this.seconds(0); + /* falls through */ + case 'second': + this.milliseconds(0); + /* falls through */ + } + + // weeks are a special case + if (units === 'week') { + this.weekday(0); + } else if (units === 'isoWeek') { + this.isoWeekday(1); + } + + // quarters are also special + if (units === 'quarter') { + this.month(Math.floor(this.month() / 3) * 3); + } + + return this; + }, + + endOf: function (units) { + units = normalizeUnits(units); + if (units === undefined || units === 'millisecond') { + return this; + } + return this.startOf(units).add(1, (units === 'isoWeek' ? 'week' : units)).subtract(1, 'ms'); + }, + + isAfter: function (input, units) { + var inputMs; + units = normalizeUnits(typeof units !== 'undefined' ? units : 'millisecond'); + if (units === 'millisecond') { + input = moment.isMoment(input) ? input : moment(input); + return +this > +input; + } else { + inputMs = moment.isMoment(input) ? +input : +moment(input); + return inputMs < +this.clone().startOf(units); + } + }, + + isBefore: function (input, units) { + var inputMs; + units = normalizeUnits(typeof units !== 'undefined' ? units : 'millisecond'); + if (units === 'millisecond') { + input = moment.isMoment(input) ? input : moment(input); + return +this < +input; + } else { + inputMs = moment.isMoment(input) ? +input : +moment(input); + return +this.clone().endOf(units) < inputMs; + } + }, + + isSame: function (input, units) { + var inputMs; + units = normalizeUnits(units || 'millisecond'); + if (units === 'millisecond') { + input = moment.isMoment(input) ? input : moment(input); + return +this === +input; + } else { + inputMs = +moment(input); + return +(this.clone().startOf(units)) <= inputMs && inputMs <= +(this.clone().endOf(units)); + } + }, + + min: deprecate( + 'moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548', + function (other) { + other = moment.apply(null, arguments); + return other < this ? this : other; + } + ), + + max: deprecate( + 'moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548', + function (other) { + other = moment.apply(null, arguments); + return other > this ? this : other; + } + ), + + // keepLocalTime = true means only change the timezone, without + // affecting the local hour. So 5:31:26 +0300 --[zone(2, true)]--> + // 5:31:26 +0200 It is possible that 5:31:26 doesn't exist int zone + // +0200, so we adjust the time as needed, to be valid. + // + // Keeping the time actually adds/subtracts (one hour) + // from the actual represented time. That is why we call updateOffset + // a second time. In case it wants us to change the offset again + // _changeInProgress == true case, then we have to adjust, because + // there is no such time in the given timezone. + zone : function (input, keepLocalTime) { + var offset = this._offset || 0, + localAdjust; + if (input != null) { + if (typeof input === 'string') { + input = timezoneMinutesFromString(input); + } + if (Math.abs(input) < 16) { + input = input * 60; + } + if (!this._isUTC && keepLocalTime) { + localAdjust = this._dateTzOffset(); + } + this._offset = input; + this._isUTC = true; + if (localAdjust != null) { + this.subtract(localAdjust, 'm'); + } + if (offset !== input) { + if (!keepLocalTime || this._changeInProgress) { + addOrSubtractDurationFromMoment(this, + moment.duration(offset - input, 'm'), 1, false); + } else if (!this._changeInProgress) { + this._changeInProgress = true; + moment.updateOffset(this, true); + this._changeInProgress = null; + } + } + } else { + return this._isUTC ? offset : this._dateTzOffset(); + } + return this; + }, + + zoneAbbr : function () { + return this._isUTC ? 'UTC' : ''; + }, + + zoneName : function () { + return this._isUTC ? 'Coordinated Universal Time' : ''; + }, + + parseZone : function () { + if (this._tzm) { + this.zone(this._tzm); + } else if (typeof this._i === 'string') { + this.zone(this._i); + } + return this; + }, + + hasAlignedHourOffset : function (input) { + if (!input) { + input = 0; + } + else { + input = moment(input).zone(); + } + + return (this.zone() - input) % 60 === 0; + }, + + daysInMonth : function () { + return daysInMonth(this.year(), this.month()); + }, + + dayOfYear : function (input) { + var dayOfYear = round((moment(this).startOf('day') - moment(this).startOf('year')) / 864e5) + 1; + return input == null ? dayOfYear : this.add((input - dayOfYear), 'd'); + }, + + quarter : function (input) { + return input == null ? Math.ceil((this.month() + 1) / 3) : this.month((input - 1) * 3 + this.month() % 3); + }, + + weekYear : function (input) { + var year = weekOfYear(this, this.localeData()._week.dow, this.localeData()._week.doy).year; + return input == null ? year : this.add((input - year), 'y'); + }, + + isoWeekYear : function (input) { + var year = weekOfYear(this, 1, 4).year; + return input == null ? year : this.add((input - year), 'y'); + }, + + week : function (input) { + var week = this.localeData().week(this); + return input == null ? week : this.add((input - week) * 7, 'd'); + }, + + isoWeek : function (input) { + var week = weekOfYear(this, 1, 4).week; + return input == null ? week : this.add((input - week) * 7, 'd'); + }, + + weekday : function (input) { + var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7; + return input == null ? weekday : this.add(input - weekday, 'd'); + }, + + isoWeekday : function (input) { + // behaves the same as moment#day except + // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6) + // as a setter, sunday should belong to the previous week. + return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7); + }, + + isoWeeksInYear : function () { + return weeksInYear(this.year(), 1, 4); + }, + + weeksInYear : function () { + var weekInfo = this.localeData()._week; + return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); + }, + + get : function (units) { + units = normalizeUnits(units); + return this[units](); + }, + + set : function (units, value) { + units = normalizeUnits(units); + if (typeof this[units] === 'function') { + this[units](value); + } + return this; + }, + + // If passed a locale key, it will set the locale for this + // instance. Otherwise, it will return the locale configuration + // variables for this instance. + locale : function (key) { + var newLocaleData; + + if (key === undefined) { + return this._locale._abbr; + } else { + newLocaleData = moment.localeData(key); + if (newLocaleData != null) { + this._locale = newLocaleData; + } + return this; + } + }, + + lang : deprecate( + 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', + function (key) { + if (key === undefined) { + return this.localeData(); + } else { + return this.locale(key); + } + } + ), + + localeData : function () { + return this._locale; + }, + + _dateTzOffset : function () { + // On Firefox.24 Date#getTimezoneOffset returns a floating point. + // https://github.com/moment/moment/pull/1871 + return Math.round(this._d.getTimezoneOffset() / 15) * 15; + } + }); + + function rawMonthSetter(mom, value) { + var dayOfMonth; + + // TODO: Move this out of here! + if (typeof value === 'string') { + value = mom.localeData().monthsParse(value); + // TODO: Another silent failure? + if (typeof value !== 'number') { + return mom; + } + } + + dayOfMonth = Math.min(mom.date(), + daysInMonth(mom.year(), value)); + mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth); + return mom; + } + + function rawGetter(mom, unit) { + return mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit](); + } + + function rawSetter(mom, unit, value) { + if (unit === 'Month') { + return rawMonthSetter(mom, value); + } else { + return mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value); + } + } + + function makeAccessor(unit, keepTime) { + return function (value) { + if (value != null) { + rawSetter(this, unit, value); + moment.updateOffset(this, keepTime); + return this; + } else { + return rawGetter(this, unit); + } + }; + } + + moment.fn.millisecond = moment.fn.milliseconds = makeAccessor('Milliseconds', false); + moment.fn.second = moment.fn.seconds = makeAccessor('Seconds', false); + moment.fn.minute = moment.fn.minutes = makeAccessor('Minutes', false); + // Setting the hour should keep the time, because the user explicitly + // specified which hour he wants. So trying to maintain the same hour (in + // a new timezone) makes sense. Adding/subtracting hours does not follow + // this rule. + moment.fn.hour = moment.fn.hours = makeAccessor('Hours', true); + // moment.fn.month is defined separately + moment.fn.date = makeAccessor('Date', true); + moment.fn.dates = deprecate('dates accessor is deprecated. Use date instead.', makeAccessor('Date', true)); + moment.fn.year = makeAccessor('FullYear', true); + moment.fn.years = deprecate('years accessor is deprecated. Use year instead.', makeAccessor('FullYear', true)); + + // add plural methods + moment.fn.days = moment.fn.day; + moment.fn.months = moment.fn.month; + moment.fn.weeks = moment.fn.week; + moment.fn.isoWeeks = moment.fn.isoWeek; + moment.fn.quarters = moment.fn.quarter; + + // add aliased format methods + moment.fn.toJSON = moment.fn.toISOString; + + /************************************ + Duration Prototype + ************************************/ + + + function daysToYears (days) { + // 400 years have 146097 days (taking into account leap year rules) + return days * 400 / 146097; + } + + function yearsToDays (years) { + // years * 365 + absRound(years / 4) - + // absRound(years / 100) + absRound(years / 400); + return years * 146097 / 400; + } + + extend(moment.duration.fn = Duration.prototype, { + + _bubble : function () { + var milliseconds = this._milliseconds, + days = this._days, + months = this._months, + data = this._data, + seconds, minutes, hours, years = 0; + + // The following code bubbles up values, see the tests for + // examples of what that means. + data.milliseconds = milliseconds % 1000; + + seconds = absRound(milliseconds / 1000); + data.seconds = seconds % 60; + + minutes = absRound(seconds / 60); + data.minutes = minutes % 60; + + hours = absRound(minutes / 60); + data.hours = hours % 24; + + days += absRound(hours / 24); + + // Accurately convert days to years, assume start from year 0. + years = absRound(daysToYears(days)); + days -= absRound(yearsToDays(years)); + + // 30 days to a month + // TODO (iskren): Use anchor date (like 1st Jan) to compute this. + months += absRound(days / 30); + days %= 30; + + // 12 months -> 1 year + years += absRound(months / 12); + months %= 12; + + data.days = days; + data.months = months; + data.years = years; + }, + + abs : function () { + this._milliseconds = Math.abs(this._milliseconds); + this._days = Math.abs(this._days); + this._months = Math.abs(this._months); + + this._data.milliseconds = Math.abs(this._data.milliseconds); + this._data.seconds = Math.abs(this._data.seconds); + this._data.minutes = Math.abs(this._data.minutes); + this._data.hours = Math.abs(this._data.hours); + this._data.months = Math.abs(this._data.months); + this._data.years = Math.abs(this._data.years); + + return this; + }, + + weeks : function () { + return absRound(this.days() / 7); + }, + + valueOf : function () { + return this._milliseconds + + this._days * 864e5 + + (this._months % 12) * 2592e6 + + toInt(this._months / 12) * 31536e6; + }, + + humanize : function (withSuffix) { + var output = relativeTime(this, !withSuffix, this.localeData()); + + if (withSuffix) { + output = this.localeData().pastFuture(+this, output); + } + + return this.localeData().postformat(output); + }, + + add : function (input, val) { + // supports only 2.0-style add(1, 's') or add(moment) + var dur = moment.duration(input, val); + + this._milliseconds += dur._milliseconds; + this._days += dur._days; + this._months += dur._months; + + this._bubble(); + + return this; + }, + + subtract : function (input, val) { + var dur = moment.duration(input, val); + + this._milliseconds -= dur._milliseconds; + this._days -= dur._days; + this._months -= dur._months; + + this._bubble(); + + return this; + }, + + get : function (units) { + units = normalizeUnits(units); + return this[units.toLowerCase() + 's'](); + }, + + as : function (units) { + var days, months; + units = normalizeUnits(units); + + if (units === 'month' || units === 'year') { + days = this._days + this._milliseconds / 864e5; + months = this._months + daysToYears(days) * 12; + return units === 'month' ? months : months / 12; + } else { + // handle milliseconds separately because of floating point math errors (issue #1867) + days = this._days + Math.round(yearsToDays(this._months / 12)); + switch (units) { + case 'week': return days / 7 + this._milliseconds / 6048e5; + case 'day': return days + this._milliseconds / 864e5; + case 'hour': return days * 24 + this._milliseconds / 36e5; + case 'minute': return days * 24 * 60 + this._milliseconds / 6e4; + case 'second': return days * 24 * 60 * 60 + this._milliseconds / 1000; + // Math.floor prevents floating point math errors here + case 'millisecond': return Math.floor(days * 24 * 60 * 60 * 1000) + this._milliseconds; + default: throw new Error('Unknown unit ' + units); + } + } + }, + + lang : moment.fn.lang, + locale : moment.fn.locale, + + toIsoString : deprecate( + 'toIsoString() is deprecated. Please use toISOString() instead ' + + '(notice the capitals)', + function () { + return this.toISOString(); + } + ), + + toISOString : function () { + // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js + var years = Math.abs(this.years()), + months = Math.abs(this.months()), + days = Math.abs(this.days()), + hours = Math.abs(this.hours()), + minutes = Math.abs(this.minutes()), + seconds = Math.abs(this.seconds() + this.milliseconds() / 1000); + + if (!this.asSeconds()) { + // this is the same as C#'s (Noda) and python (isodate)... + // but not other JS (goog.date) + return 'P0D'; + } + + return (this.asSeconds() < 0 ? '-' : '') + + 'P' + + (years ? years + 'Y' : '') + + (months ? months + 'M' : '') + + (days ? days + 'D' : '') + + ((hours || minutes || seconds) ? 'T' : '') + + (hours ? hours + 'H' : '') + + (minutes ? minutes + 'M' : '') + + (seconds ? seconds + 'S' : ''); + }, + + localeData : function () { + return this._locale; + } + }); + + moment.duration.fn.toString = moment.duration.fn.toISOString; + + function makeDurationGetter(name) { + moment.duration.fn[name] = function () { + return this._data[name]; + }; + } + + for (i in unitMillisecondFactors) { + if (hasOwnProp(unitMillisecondFactors, i)) { + makeDurationGetter(i.toLowerCase()); + } + } + + moment.duration.fn.asMilliseconds = function () { + return this.as('ms'); + }; + moment.duration.fn.asSeconds = function () { + return this.as('s'); + }; + moment.duration.fn.asMinutes = function () { + return this.as('m'); + }; + moment.duration.fn.asHours = function () { + return this.as('h'); + }; + moment.duration.fn.asDays = function () { + return this.as('d'); + }; + moment.duration.fn.asWeeks = function () { + return this.as('weeks'); + }; + moment.duration.fn.asMonths = function () { + return this.as('M'); + }; + moment.duration.fn.asYears = function () { + return this.as('y'); + }; + + /************************************ + Default Locale + ************************************/ + + + // Set default locale, other locale will inherit from English. + moment.locale('en', { + ordinalParse: /\d{1,2}(th|st|nd|rd)/, + ordinal : function (number) { + var b = number % 10, + output = (toInt(number % 100 / 10) === 1) ? 'th' : + (b === 1) ? 'st' : + (b === 2) ? 'nd' : + (b === 3) ? 'rd' : 'th'; + return number + output; + } + }); + + /* EMBED_LOCALES */ + + /************************************ + Exposing Moment + ************************************/ + + function makeGlobal(shouldDeprecate) { + /*global ender:false */ + if (typeof ender !== 'undefined') { + return; + } + oldGlobalMoment = globalScope.moment; + if (shouldDeprecate) { + globalScope.moment = deprecate( + 'Accessing Moment through the global scope is ' + + 'deprecated, and will be removed in an upcoming ' + + 'release.', + moment); + } else { + globalScope.moment = moment; + } + } + + // CommonJS module is defined + if (hasModule) { + module.exports = moment; + } else if (typeof define === 'function' && define.amd) { + define('moment', function (require, exports, module) { + if (module.config && module.config() && module.config().noGlobal === true) { + // release the global variable + globalScope.moment = oldGlobalMoment; + } + + return moment; + }); + makeGlobal(true); + } else { + makeGlobal(); + } +}).call(this); \ No newline at end of file diff --git a/assets/js/admin/moment.min.js b/assets/js/admin/moment.min.js new file mode 100644 index 0000000..9fb34ee --- /dev/null +++ b/assets/js/admin/moment.min.js @@ -0,0 +1,7 @@ +//! moment.js +//! version : 2.8.4 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com +(function(a){function b(a,b,c){switch(arguments.length){case 2:return null!=a?a:b;case 3:return null!=a?a:null!=b?b:c;default:throw new Error("Implement me")}}function c(a,b){return zb.call(a,b)}function d(){return{empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1}}function e(a){tb.suppressDeprecationWarnings===!1&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+a)}function f(a,b){var c=!0;return m(function(){return c&&(e(a),c=!1),b.apply(this,arguments)},b)}function g(a,b){qc[a]||(e(b),qc[a]=!0)}function h(a,b){return function(c){return p(a.call(this,c),b)}}function i(a,b){return function(c){return this.localeData().ordinal(a.call(this,c),b)}}function j(){}function k(a,b){b!==!1&&F(a),n(this,a),this._d=new Date(+a._d)}function l(a){var b=y(a),c=b.year||0,d=b.quarter||0,e=b.month||0,f=b.week||0,g=b.day||0,h=b.hour||0,i=b.minute||0,j=b.second||0,k=b.millisecond||0;this._milliseconds=+k+1e3*j+6e4*i+36e5*h,this._days=+g+7*f,this._months=+e+3*d+12*c,this._data={},this._locale=tb.localeData(),this._bubble()}function m(a,b){for(var d in b)c(b,d)&&(a[d]=b[d]);return c(b,"toString")&&(a.toString=b.toString),c(b,"valueOf")&&(a.valueOf=b.valueOf),a}function n(a,b){var c,d,e;if("undefined"!=typeof b._isAMomentObject&&(a._isAMomentObject=b._isAMomentObject),"undefined"!=typeof b._i&&(a._i=b._i),"undefined"!=typeof b._f&&(a._f=b._f),"undefined"!=typeof b._l&&(a._l=b._l),"undefined"!=typeof b._strict&&(a._strict=b._strict),"undefined"!=typeof b._tzm&&(a._tzm=b._tzm),"undefined"!=typeof b._isUTC&&(a._isUTC=b._isUTC),"undefined"!=typeof b._offset&&(a._offset=b._offset),"undefined"!=typeof b._pf&&(a._pf=b._pf),"undefined"!=typeof b._locale&&(a._locale=b._locale),Ib.length>0)for(c in Ib)d=Ib[c],e=b[d],"undefined"!=typeof e&&(a[d]=e);return a}function o(a){return 0>a?Math.ceil(a):Math.floor(a)}function p(a,b,c){for(var d=""+Math.abs(a),e=a>=0;d.lengthd;d++)(c&&a[d]!==b[d]||!c&&A(a[d])!==A(b[d]))&&g++;return g+f}function x(a){if(a){var b=a.toLowerCase().replace(/(.)s$/,"$1");a=jc[a]||kc[b]||b}return a}function y(a){var b,d,e={};for(d in a)c(a,d)&&(b=x(d),b&&(e[b]=a[d]));return e}function z(b){var c,d;if(0===b.indexOf("week"))c=7,d="day";else{if(0!==b.indexOf("month"))return;c=12,d="month"}tb[b]=function(e,f){var g,h,i=tb._locale[b],j=[];if("number"==typeof e&&(f=e,e=a),h=function(a){var b=tb().utc().set(d,a);return i.call(tb._locale,b,e||"")},null!=f)return h(f);for(g=0;c>g;g++)j.push(h(g));return j}}function A(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=b>=0?Math.floor(b):Math.ceil(b)),c}function B(a,b){return new Date(Date.UTC(a,b+1,0)).getUTCDate()}function C(a,b,c){return hb(tb([a,11,31+b-c]),b,c).week}function D(a){return E(a)?366:365}function E(a){return a%4===0&&a%100!==0||a%400===0}function F(a){var b;a._a&&-2===a._pf.overflow&&(b=a._a[Bb]<0||a._a[Bb]>11?Bb:a._a[Cb]<1||a._a[Cb]>B(a._a[Ab],a._a[Bb])?Cb:a._a[Db]<0||a._a[Db]>24||24===a._a[Db]&&(0!==a._a[Eb]||0!==a._a[Fb]||0!==a._a[Gb])?Db:a._a[Eb]<0||a._a[Eb]>59?Eb:a._a[Fb]<0||a._a[Fb]>59?Fb:a._a[Gb]<0||a._a[Gb]>999?Gb:-1,a._pf._overflowDayOfYear&&(Ab>b||b>Cb)&&(b=Cb),a._pf.overflow=b)}function G(b){return null==b._isValid&&(b._isValid=!isNaN(b._d.getTime())&&b._pf.overflow<0&&!b._pf.empty&&!b._pf.invalidMonth&&!b._pf.nullInput&&!b._pf.invalidFormat&&!b._pf.userInvalidated,b._strict&&(b._isValid=b._isValid&&0===b._pf.charsLeftOver&&0===b._pf.unusedTokens.length&&b._pf.bigHour===a)),b._isValid}function H(a){return a?a.toLowerCase().replace("_","-"):a}function I(a){for(var b,c,d,e,f=0;f0;){if(d=J(e.slice(0,b).join("-")))return d;if(c&&c.length>=b&&w(e,c,!0)>=b-1)break;b--}f++}return null}function J(a){var b=null;if(!Hb[a]&&Jb)try{b=tb.locale(),require("./locale/"+a),tb.locale(b)}catch(c){}return Hb[a]}function K(a,b){var c,d;return b._isUTC?(c=b.clone(),d=(tb.isMoment(a)||v(a)?+a:+tb(a))-+c,c._d.setTime(+c._d+d),tb.updateOffset(c,!1),c):tb(a).local()}function L(a){return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g,""):a.replace(/\\/g,"")}function M(a){var b,c,d=a.match(Nb);for(b=0,c=d.length;c>b;b++)d[b]=pc[d[b]]?pc[d[b]]:L(d[b]);return function(e){var f="";for(b=0;c>b;b++)f+=d[b]instanceof Function?d[b].call(e,a):d[b];return f}}function N(a,b){return a.isValid()?(b=O(b,a.localeData()),lc[b]||(lc[b]=M(b)),lc[b](a)):a.localeData().invalidDate()}function O(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(Ob.lastIndex=0;d>=0&&Ob.test(a);)a=a.replace(Ob,c),Ob.lastIndex=0,d-=1;return a}function P(a,b){var c,d=b._strict;switch(a){case"Q":return Zb;case"DDDD":return _b;case"YYYY":case"GGGG":case"gggg":return d?ac:Rb;case"Y":case"G":case"g":return cc;case"YYYYYY":case"YYYYY":case"GGGGG":case"ggggg":return d?bc:Sb;case"S":if(d)return Zb;case"SS":if(d)return $b;case"SSS":if(d)return _b;case"DDD":return Qb;case"MMM":case"MMMM":case"dd":case"ddd":case"dddd":return Ub;case"a":case"A":return b._locale._meridiemParse;case"x":return Xb;case"X":return Yb;case"Z":case"ZZ":return Vb;case"T":return Wb;case"SSSS":return Tb;case"MM":case"DD":case"YY":case"GG":case"gg":case"HH":case"hh":case"mm":case"ss":case"ww":case"WW":return d?$b:Pb;case"M":case"D":case"d":case"H":case"h":case"m":case"s":case"w":case"W":case"e":case"E":return Pb;case"Do":return d?b._locale._ordinalParse:b._locale._ordinalParseLenient;default:return c=new RegExp(Y(X(a.replace("\\","")),"i"))}}function Q(a){a=a||"";var b=a.match(Vb)||[],c=b[b.length-1]||[],d=(c+"").match(hc)||["-",0,0],e=+(60*d[1])+A(d[2]);return"+"===d[0]?-e:e}function R(a,b,c){var d,e=c._a;switch(a){case"Q":null!=b&&(e[Bb]=3*(A(b)-1));break;case"M":case"MM":null!=b&&(e[Bb]=A(b)-1);break;case"MMM":case"MMMM":d=c._locale.monthsParse(b,a,c._strict),null!=d?e[Bb]=d:c._pf.invalidMonth=b;break;case"D":case"DD":null!=b&&(e[Cb]=A(b));break;case"Do":null!=b&&(e[Cb]=A(parseInt(b.match(/\d{1,2}/)[0],10)));break;case"DDD":case"DDDD":null!=b&&(c._dayOfYear=A(b));break;case"YY":e[Ab]=tb.parseTwoDigitYear(b);break;case"YYYY":case"YYYYY":case"YYYYYY":e[Ab]=A(b);break;case"a":case"A":c._isPm=c._locale.isPM(b);break;case"h":case"hh":c._pf.bigHour=!0;case"H":case"HH":e[Db]=A(b);break;case"m":case"mm":e[Eb]=A(b);break;case"s":case"ss":e[Fb]=A(b);break;case"S":case"SS":case"SSS":case"SSSS":e[Gb]=A(1e3*("0."+b));break;case"x":c._d=new Date(A(b));break;case"X":c._d=new Date(1e3*parseFloat(b));break;case"Z":case"ZZ":c._useUTC=!0,c._tzm=Q(b);break;case"dd":case"ddd":case"dddd":d=c._locale.weekdaysParse(b),null!=d?(c._w=c._w||{},c._w.d=d):c._pf.invalidWeekday=b;break;case"w":case"ww":case"W":case"WW":case"d":case"e":case"E":a=a.substr(0,1);case"gggg":case"GGGG":case"GGGGG":a=a.substr(0,2),b&&(c._w=c._w||{},c._w[a]=A(b));break;case"gg":case"GG":c._w=c._w||{},c._w[a]=tb.parseTwoDigitYear(b)}}function S(a){var c,d,e,f,g,h,i;c=a._w,null!=c.GG||null!=c.W||null!=c.E?(g=1,h=4,d=b(c.GG,a._a[Ab],hb(tb(),1,4).year),e=b(c.W,1),f=b(c.E,1)):(g=a._locale._week.dow,h=a._locale._week.doy,d=b(c.gg,a._a[Ab],hb(tb(),g,h).year),e=b(c.w,1),null!=c.d?(f=c.d,g>f&&++e):f=null!=c.e?c.e+g:g),i=ib(d,e,f,h,g),a._a[Ab]=i.year,a._dayOfYear=i.dayOfYear}function T(a){var c,d,e,f,g=[];if(!a._d){for(e=V(a),a._w&&null==a._a[Cb]&&null==a._a[Bb]&&S(a),a._dayOfYear&&(f=b(a._a[Ab],e[Ab]),a._dayOfYear>D(f)&&(a._pf._overflowDayOfYear=!0),d=db(f,0,a._dayOfYear),a._a[Bb]=d.getUTCMonth(),a._a[Cb]=d.getUTCDate()),c=0;3>c&&null==a._a[c];++c)a._a[c]=g[c]=e[c];for(;7>c;c++)a._a[c]=g[c]=null==a._a[c]?2===c?1:0:a._a[c];24===a._a[Db]&&0===a._a[Eb]&&0===a._a[Fb]&&0===a._a[Gb]&&(a._nextDay=!0,a._a[Db]=0),a._d=(a._useUTC?db:cb).apply(null,g),null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()+a._tzm),a._nextDay&&(a._a[Db]=24)}}function U(a){var b;a._d||(b=y(a._i),a._a=[b.year,b.month,b.day||b.date,b.hour,b.minute,b.second,b.millisecond],T(a))}function V(a){var b=new Date;return a._useUTC?[b.getUTCFullYear(),b.getUTCMonth(),b.getUTCDate()]:[b.getFullYear(),b.getMonth(),b.getDate()]}function W(b){if(b._f===tb.ISO_8601)return void $(b);b._a=[],b._pf.empty=!0;var c,d,e,f,g,h=""+b._i,i=h.length,j=0;for(e=O(b._f,b._locale).match(Nb)||[],c=0;c0&&b._pf.unusedInput.push(g),h=h.slice(h.indexOf(d)+d.length),j+=d.length),pc[f]?(d?b._pf.empty=!1:b._pf.unusedTokens.push(f),R(f,d,b)):b._strict&&!d&&b._pf.unusedTokens.push(f);b._pf.charsLeftOver=i-j,h.length>0&&b._pf.unusedInput.push(h),b._pf.bigHour===!0&&b._a[Db]<=12&&(b._pf.bigHour=a),b._isPm&&b._a[Db]<12&&(b._a[Db]+=12),b._isPm===!1&&12===b._a[Db]&&(b._a[Db]=0),T(b),F(b)}function X(a){return a.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e})}function Y(a){return a.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function Z(a){var b,c,e,f,g;if(0===a._f.length)return a._pf.invalidFormat=!0,void(a._d=new Date(0/0));for(f=0;fg)&&(e=g,c=b));m(a,c||b)}function $(a){var b,c,d=a._i,e=dc.exec(d);if(e){for(a._pf.iso=!0,b=0,c=fc.length;c>b;b++)if(fc[b][1].exec(d)){a._f=fc[b][0]+(e[6]||" ");break}for(b=0,c=gc.length;c>b;b++)if(gc[b][1].exec(d)){a._f+=gc[b][0];break}d.match(Vb)&&(a._f+="Z"),W(a)}else a._isValid=!1}function _(a){$(a),a._isValid===!1&&(delete a._isValid,tb.createFromInputFallback(a))}function ab(a,b){var c,d=[];for(c=0;ca&&h.setFullYear(a),h}function db(a){var b=new Date(Date.UTC.apply(null,arguments));return 1970>a&&b.setUTCFullYear(a),b}function eb(a,b){if("string"==typeof a)if(isNaN(a)){if(a=b.weekdaysParse(a),"number"!=typeof a)return null}else a=parseInt(a,10);return a}function fb(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function gb(a,b,c){var d=tb.duration(a).abs(),e=yb(d.as("s")),f=yb(d.as("m")),g=yb(d.as("h")),h=yb(d.as("d")),i=yb(d.as("M")),j=yb(d.as("y")),k=e0,k[4]=c,fb.apply({},k)}function hb(a,b,c){var d,e=c-b,f=c-a.day();return f>e&&(f-=7),e-7>f&&(f+=7),d=tb(a).add(f,"d"),{week:Math.ceil(d.dayOfYear()/7),year:d.year()}}function ib(a,b,c,d,e){var f,g,h=db(a,0,1).getUTCDay();return h=0===h?7:h,c=null!=c?c:e,f=e-h+(h>d?7:0)-(e>h?7:0),g=7*(b-1)+(c-e)+f+1,{year:g>0?a:a-1,dayOfYear:g>0?g:D(a-1)+g}}function jb(b){var c,d=b._i,e=b._f;return b._locale=b._locale||tb.localeData(b._l),null===d||e===a&&""===d?tb.invalid({nullInput:!0}):("string"==typeof d&&(b._i=d=b._locale.preparse(d)),tb.isMoment(d)?new k(d,!0):(e?u(e)?Z(b):W(b):bb(b),c=new k(b),c._nextDay&&(c.add(1,"d"),c._nextDay=a),c))}function kb(a,b){var c,d;if(1===b.length&&u(b[0])&&(b=b[0]),!b.length)return tb();for(c=b[0],d=1;d=0?"+":"-";return b+p(Math.abs(a),6)},gg:function(){return p(this.weekYear()%100,2)},gggg:function(){return p(this.weekYear(),4)},ggggg:function(){return p(this.weekYear(),5)},GG:function(){return p(this.isoWeekYear()%100,2)},GGGG:function(){return p(this.isoWeekYear(),4)},GGGGG:function(){return p(this.isoWeekYear(),5)},e:function(){return this.weekday()},E:function(){return this.isoWeekday()},a:function(){return this.localeData().meridiem(this.hours(),this.minutes(),!0)},A:function(){return this.localeData().meridiem(this.hours(),this.minutes(),!1)},H:function(){return this.hours()},h:function(){return this.hours()%12||12},m:function(){return this.minutes()},s:function(){return this.seconds()},S:function(){return A(this.milliseconds()/100)},SS:function(){return p(A(this.milliseconds()/10),2)},SSS:function(){return p(this.milliseconds(),3)},SSSS:function(){return p(this.milliseconds(),3)},Z:function(){var a=-this.zone(),b="+";return 0>a&&(a=-a,b="-"),b+p(A(a/60),2)+":"+p(A(a)%60,2)},ZZ:function(){var a=-this.zone(),b="+";return 0>a&&(a=-a,b="-"),b+p(A(a/60),2)+p(A(a)%60,2)},z:function(){return this.zoneAbbr()},zz:function(){return this.zoneName()},x:function(){return this.valueOf()},X:function(){return this.unix()},Q:function(){return this.quarter()}},qc={},rc=["months","monthsShort","weekdays","weekdaysShort","weekdaysMin"];nc.length;)vb=nc.pop(),pc[vb+"o"]=i(pc[vb],vb);for(;oc.length;)vb=oc.pop(),pc[vb+vb]=h(pc[vb],2);pc.DDDD=h(pc.DDD,3),m(j.prototype,{set:function(a){var b,c;for(c in a)b=a[c],"function"==typeof b?this[c]=b:this["_"+c]=b;this._ordinalParseLenient=new RegExp(this._ordinalParse.source+"|"+/\d{1,2}/.source)},_months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),months:function(a){return this._months[a.month()]},_monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),monthsShort:function(a){return this._monthsShort[a.month()]},monthsParse:function(a,b,c){var d,e,f;for(this._monthsParse||(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[]),d=0;12>d;d++){if(e=tb.utc([2e3,d]),c&&!this._longMonthsParse[d]&&(this._longMonthsParse[d]=new RegExp("^"+this.months(e,"").replace(".","")+"$","i"),this._shortMonthsParse[d]=new RegExp("^"+this.monthsShort(e,"").replace(".","")+"$","i")),c||this._monthsParse[d]||(f="^"+this.months(e,"")+"|^"+this.monthsShort(e,""),this._monthsParse[d]=new RegExp(f.replace(".",""),"i")),c&&"MMMM"===b&&this._longMonthsParse[d].test(a))return d;if(c&&"MMM"===b&&this._shortMonthsParse[d].test(a))return d;if(!c&&this._monthsParse[d].test(a))return d}},_weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdays:function(a){return this._weekdays[a.day()]},_weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysShort:function(a){return this._weekdaysShort[a.day()]},_weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),weekdaysMin:function(a){return this._weekdaysMin[a.day()]},weekdaysParse:function(a){var b,c,d;for(this._weekdaysParse||(this._weekdaysParse=[]),b=0;7>b;b++)if(this._weekdaysParse[b]||(c=tb([2e3,1]).day(b),d="^"+this.weekdays(c,"")+"|^"+this.weekdaysShort(c,"")+"|^"+this.weekdaysMin(c,""),this._weekdaysParse[b]=new RegExp(d.replace(".",""),"i")),this._weekdaysParse[b].test(a))return b},_longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY LT",LLLL:"dddd, MMMM D, YYYY LT"},longDateFormat:function(a){var b=this._longDateFormat[a];return!b&&this._longDateFormat[a.toUpperCase()]&&(b=this._longDateFormat[a.toUpperCase()].replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a]=b),b},isPM:function(a){return"p"===(a+"").toLowerCase().charAt(0)},_meridiemParse:/[ap]\.?m?\.?/i,meridiem:function(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"},_calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},calendar:function(a,b,c){var d=this._calendar[a];return"function"==typeof d?d.apply(b,[c]):d},_relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},relativeTime:function(a,b,c,d){var e=this._relativeTime[c];return"function"==typeof e?e(a,b,c,d):e.replace(/%d/i,a)},pastFuture:function(a,b){var c=this._relativeTime[a>0?"future":"past"];return"function"==typeof c?c(b):c.replace(/%s/i,b)},ordinal:function(a){return this._ordinal.replace("%d",a)},_ordinal:"%d",_ordinalParse:/\d{1,2}/,preparse:function(a){return a},postformat:function(a){return a},week:function(a){return hb(a,this._week.dow,this._week.doy).week},_week:{dow:0,doy:6},_invalidDate:"Invalid date",invalidDate:function(){return this._invalidDate}}),tb=function(b,c,e,f){var g;return"boolean"==typeof e&&(f=e,e=a),g={},g._isAMomentObject=!0,g._i=b,g._f=c,g._l=e,g._strict=f,g._isUTC=!1,g._pf=d(),jb(g)},tb.suppressDeprecationWarnings=!1,tb.createFromInputFallback=f("moment construction falls back to js Date. This is discouraged and will be removed in upcoming major release. Please refer to https://github.com/moment/moment/issues/1407 for more info.",function(a){a._d=new Date(a._i+(a._useUTC?" UTC":""))}),tb.min=function(){var a=[].slice.call(arguments,0);return kb("isBefore",a)},tb.max=function(){var a=[].slice.call(arguments,0);return kb("isAfter",a)},tb.utc=function(b,c,e,f){var g;return"boolean"==typeof e&&(f=e,e=a),g={},g._isAMomentObject=!0,g._useUTC=!0,g._isUTC=!0,g._l=e,g._i=b,g._f=c,g._strict=f,g._pf=d(),jb(g).utc()},tb.unix=function(a){return tb(1e3*a)},tb.duration=function(a,b){var d,e,f,g,h=a,i=null;return tb.isDuration(a)?h={ms:a._milliseconds,d:a._days,M:a._months}:"number"==typeof a?(h={},b?h[b]=a:h.milliseconds=a):(i=Lb.exec(a))?(d="-"===i[1]?-1:1,h={y:0,d:A(i[Cb])*d,h:A(i[Db])*d,m:A(i[Eb])*d,s:A(i[Fb])*d,ms:A(i[Gb])*d}):(i=Mb.exec(a))?(d="-"===i[1]?-1:1,f=function(a){var b=a&&parseFloat(a.replace(",","."));return(isNaN(b)?0:b)*d},h={y:f(i[2]),M:f(i[3]),d:f(i[4]),h:f(i[5]),m:f(i[6]),s:f(i[7]),w:f(i[8])}):"object"==typeof h&&("from"in h||"to"in h)&&(g=r(tb(h.from),tb(h.to)),h={},h.ms=g.milliseconds,h.M=g.months),e=new l(h),tb.isDuration(a)&&c(a,"_locale")&&(e._locale=a._locale),e},tb.version=wb,tb.defaultFormat=ec,tb.ISO_8601=function(){},tb.momentProperties=Ib,tb.updateOffset=function(){},tb.relativeTimeThreshold=function(b,c){return mc[b]===a?!1:c===a?mc[b]:(mc[b]=c,!0)},tb.lang=f("moment.lang is deprecated. Use moment.locale instead.",function(a,b){return tb.locale(a,b)}),tb.locale=function(a,b){var c;return a&&(c="undefined"!=typeof b?tb.defineLocale(a,b):tb.localeData(a),c&&(tb.duration._locale=tb._locale=c)),tb._locale._abbr},tb.defineLocale=function(a,b){return null!==b?(b.abbr=a,Hb[a]||(Hb[a]=new j),Hb[a].set(b),tb.locale(a),Hb[a]):(delete Hb[a],null)},tb.langData=f("moment.langData is deprecated. Use moment.localeData instead.",function(a){return tb.localeData(a)}),tb.localeData=function(a){var b;if(a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr),!a)return tb._locale;if(!u(a)){if(b=J(a))return b;a=[a]}return I(a)},tb.isMoment=function(a){return a instanceof k||null!=a&&c(a,"_isAMomentObject")},tb.isDuration=function(a){return a instanceof l};for(vb=rc.length-1;vb>=0;--vb)z(rc[vb]);tb.normalizeUnits=function(a){return x(a)},tb.invalid=function(a){var b=tb.utc(0/0);return null!=a?m(b._pf,a):b._pf.userInvalidated=!0,b},tb.parseZone=function(){return tb.apply(null,arguments).parseZone()},tb.parseTwoDigitYear=function(a){return A(a)+(A(a)>68?1900:2e3)},m(tb.fn=k.prototype,{clone:function(){return tb(this)},valueOf:function(){return+this._d+6e4*(this._offset||0)},unix:function(){return Math.floor(+this/1e3)},toString:function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},toDate:function(){return this._offset?new Date(+this):this._d},toISOString:function(){var a=tb(this).utc();return 00:!1},parsingFlags:function(){return m({},this._pf)},invalidAt:function(){return this._pf.overflow},utc:function(a){return this.zone(0,a)},local:function(a){return this._isUTC&&(this.zone(0,a),this._isUTC=!1,a&&this.add(this._dateTzOffset(),"m")),this},format:function(a){var b=N(this,a||tb.defaultFormat);return this.localeData().postformat(b)},add:s(1,"add"),subtract:s(-1,"subtract"),diff:function(a,b,c){var d,e,f,g=K(a,this),h=6e4*(this.zone()-g.zone());return b=x(b),"year"===b||"month"===b?(d=432e5*(this.daysInMonth()+g.daysInMonth()),e=12*(this.year()-g.year())+(this.month()-g.month()),f=this-tb(this).startOf("month")-(g-tb(g).startOf("month")),f-=6e4*(this.zone()-tb(this).startOf("month").zone()-(g.zone()-tb(g).startOf("month").zone())),e+=f/d,"year"===b&&(e/=12)):(d=this-g,e="second"===b?d/1e3:"minute"===b?d/6e4:"hour"===b?d/36e5:"day"===b?(d-h)/864e5:"week"===b?(d-h)/6048e5:d),c?e:o(e)},from:function(a,b){return tb.duration({to:this,from:a}).locale(this.locale()).humanize(!b)},fromNow:function(a){return this.from(tb(),a)},calendar:function(a){var b=a||tb(),c=K(b,this).startOf("day"),d=this.diff(c,"days",!0),e=-6>d?"sameElse":-1>d?"lastWeek":0>d?"lastDay":1>d?"sameDay":2>d?"nextDay":7>d?"nextWeek":"sameElse";return this.format(this.localeData().calendar(e,this,tb(b)))},isLeapYear:function(){return E(this.year())},isDST:function(){return this.zone()+a):(c=tb.isMoment(a)?+a:+tb(a),c<+this.clone().startOf(b))},isBefore:function(a,b){var c;return b=x("undefined"!=typeof b?b:"millisecond"),"millisecond"===b?(a=tb.isMoment(a)?a:tb(a),+a>+this):(c=tb.isMoment(a)?+a:+tb(a),+this.clone().endOf(b)a?this:a}),max:f("moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548",function(a){return a=tb.apply(null,arguments),a>this?this:a}),zone:function(a,b){var c,d=this._offset||0;return null==a?this._isUTC?d:this._dateTzOffset():("string"==typeof a&&(a=Q(a)),Math.abs(a)<16&&(a=60*a),!this._isUTC&&b&&(c=this._dateTzOffset()),this._offset=a,this._isUTC=!0,null!=c&&this.subtract(c,"m"),d!==a&&(!b||this._changeInProgress?t(this,tb.duration(d-a,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,tb.updateOffset(this,!0),this._changeInProgress=null)),this)},zoneAbbr:function(){return this._isUTC?"UTC":""},zoneName:function(){return this._isUTC?"Coordinated Universal Time":""},parseZone:function(){return this._tzm?this.zone(this._tzm):"string"==typeof this._i&&this.zone(this._i),this},hasAlignedHourOffset:function(a){return a=a?tb(a).zone():0,(this.zone()-a)%60===0},daysInMonth:function(){return B(this.year(),this.month())},dayOfYear:function(a){var b=yb((tb(this).startOf("day")-tb(this).startOf("year"))/864e5)+1;return null==a?b:this.add(a-b,"d")},quarter:function(a){return null==a?Math.ceil((this.month()+1)/3):this.month(3*(a-1)+this.month()%3)},weekYear:function(a){var b=hb(this,this.localeData()._week.dow,this.localeData()._week.doy).year;return null==a?b:this.add(a-b,"y")},isoWeekYear:function(a){var b=hb(this,1,4).year;return null==a?b:this.add(a-b,"y")},week:function(a){var b=this.localeData().week(this);return null==a?b:this.add(7*(a-b),"d")},isoWeek:function(a){var b=hb(this,1,4).week;return null==a?b:this.add(7*(a-b),"d")},weekday:function(a){var b=(this.day()+7-this.localeData()._week.dow)%7;return null==a?b:this.add(a-b,"d")},isoWeekday:function(a){return null==a?this.day()||7:this.day(this.day()%7?a:a-7)},isoWeeksInYear:function(){return C(this.year(),1,4)},weeksInYear:function(){var a=this.localeData()._week;return C(this.year(),a.dow,a.doy)},get:function(a){return a=x(a),this[a]()},set:function(a,b){return a=x(a),"function"==typeof this[a]&&this[a](b),this},locale:function(b){var c;return b===a?this._locale._abbr:(c=tb.localeData(b),null!=c&&(this._locale=c),this)},lang:f("moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.",function(b){return b===a?this.localeData():this.locale(b)}),localeData:function(){return this._locale},_dateTzOffset:function(){return 15*Math.round(this._d.getTimezoneOffset()/15)}}),tb.fn.millisecond=tb.fn.milliseconds=ob("Milliseconds",!1),tb.fn.second=tb.fn.seconds=ob("Seconds",!1),tb.fn.minute=tb.fn.minutes=ob("Minutes",!1),tb.fn.hour=tb.fn.hours=ob("Hours",!0),tb.fn.date=ob("Date",!0),tb.fn.dates=f("dates accessor is deprecated. Use date instead.",ob("Date",!0)),tb.fn.year=ob("FullYear",!0),tb.fn.years=f("years accessor is deprecated. Use year instead.",ob("FullYear",!0)),tb.fn.days=tb.fn.day,tb.fn.months=tb.fn.month,tb.fn.weeks=tb.fn.week,tb.fn.isoWeeks=tb.fn.isoWeek,tb.fn.quarters=tb.fn.quarter,tb.fn.toJSON=tb.fn.toISOString,m(tb.duration.fn=l.prototype,{_bubble:function(){var a,b,c,d=this._milliseconds,e=this._days,f=this._months,g=this._data,h=0;g.milliseconds=d%1e3,a=o(d/1e3),g.seconds=a%60,b=o(a/60),g.minutes=b%60,c=o(b/60),g.hours=c%24,e+=o(c/24),h=o(pb(e)),e-=o(qb(h)),f+=o(e/30),e%=30,h+=o(f/12),f%=12,g.days=e,g.months=f,g.years=h},abs:function(){return this._milliseconds=Math.abs(this._milliseconds),this._days=Math.abs(this._days),this._months=Math.abs(this._months),this._data.milliseconds=Math.abs(this._data.milliseconds),this._data.seconds=Math.abs(this._data.seconds),this._data.minutes=Math.abs(this._data.minutes),this._data.hours=Math.abs(this._data.hours),this._data.months=Math.abs(this._data.months),this._data.years=Math.abs(this._data.years),this},weeks:function(){return o(this.days()/7)},valueOf:function(){return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*A(this._months/12)},humanize:function(a){var b=gb(this,!a,this.localeData());return a&&(b=this.localeData().pastFuture(+this,b)),this.localeData().postformat(b)},add:function(a,b){var c=tb.duration(a,b);return this._milliseconds+=c._milliseconds,this._days+=c._days,this._months+=c._months,this._bubble(),this},subtract:function(a,b){var c=tb.duration(a,b);return this._milliseconds-=c._milliseconds,this._days-=c._days,this._months-=c._months,this._bubble(),this},get:function(a){return a=x(a),this[a.toLowerCase()+"s"]()},as:function(a){var b,c;if(a=x(a),"month"===a||"year"===a)return b=this._days+this._milliseconds/864e5,c=this._months+12*pb(b),"month"===a?c:c/12;switch(b=this._days+Math.round(qb(this._months/12)),a){case"week":return b/7+this._milliseconds/6048e5;case"day":return b+this._milliseconds/864e5;case"hour":return 24*b+this._milliseconds/36e5;case"minute":return 24*b*60+this._milliseconds/6e4;case"second":return 24*b*60*60+this._milliseconds/1e3; +case"millisecond":return Math.floor(24*b*60*60*1e3)+this._milliseconds;default:throw new Error("Unknown unit "+a)}},lang:tb.fn.lang,locale:tb.fn.locale,toIsoString:f("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",function(){return this.toISOString()}),toISOString:function(){var a=Math.abs(this.years()),b=Math.abs(this.months()),c=Math.abs(this.days()),d=Math.abs(this.hours()),e=Math.abs(this.minutes()),f=Math.abs(this.seconds()+this.milliseconds()/1e3);return this.asSeconds()?(this.asSeconds()<0?"-":"")+"P"+(a?a+"Y":"")+(b?b+"M":"")+(c?c+"D":"")+(d||e||f?"T":"")+(d?d+"H":"")+(e?e+"M":"")+(f?f+"S":""):"P0D"},localeData:function(){return this._locale}}),tb.duration.fn.toString=tb.duration.fn.toISOString;for(vb in ic)c(ic,vb)&&rb(vb.toLowerCase());tb.duration.fn.asMilliseconds=function(){return this.as("ms")},tb.duration.fn.asSeconds=function(){return this.as("s")},tb.duration.fn.asMinutes=function(){return this.as("m")},tb.duration.fn.asHours=function(){return this.as("h")},tb.duration.fn.asDays=function(){return this.as("d")},tb.duration.fn.asWeeks=function(){return this.as("weeks")},tb.duration.fn.asMonths=function(){return this.as("M")},tb.duration.fn.asYears=function(){return this.as("y")},tb.locale("en",{ordinalParse:/\d{1,2}(th|st|nd|rd)/,ordinal:function(a){var b=a%10,c=1===A(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c}}),Jb?module.exports=tb:"function"==typeof define&&define.amd?(define("moment",function(a,b,c){return c.config&&c.config()&&c.config().noGlobal===!0&&(xb.moment=ub),tb}),sb(!0)):sb()}).call(this); \ No newline at end of file diff --git a/assets/js/wcs-upgrade.js b/assets/js/wcs-upgrade.js new file mode 100644 index 0000000..1ee2b8d --- /dev/null +++ b/assets/js/wcs-upgrade.js @@ -0,0 +1,206 @@ +jQuery(document).ready(function($){ + var upgrade_start_time = null, + total_subscriptions = wcs_update_script_data.subscription_count; + + $('#update-messages').slideUp(); + $('#upgrade-step-3').slideUp(); + + $('form#subscriptions-upgrade').on('submit',function(e){ + $('#update-welcome').slideUp(600); + $('#update-messages').slideDown(600); + if('true'==wcs_update_script_data.really_old_version){ + wcs_ajax_update_really_old_version(); + } else if('true'==wcs_update_script_data.upgrade_to_1_5){ + wcs_ajax_update_products(); + wcs_ajax_update_hooks(); + } else if('true'==wcs_update_script_data.upgrade_to_2_0){ + wcs_ajax_update_subscriptions(); + } else if('true'==wcs_update_script_data.repair_2_0){ + wcs_ajax_repair_subscriptions(); + } else { + wcs_ajax_update_complete(); + } + e.preventDefault(); + }); + function wcs_ajax_update_really_old_version(){ + $.ajax({ + url: wcs_update_script_data.ajax_url, + type: 'POST', + data: { + action: 'wcs_upgrade', + upgrade_step: 'really_old_version', + nonce: wcs_update_script_data.upgrade_nonce + }, + success: function(results) { + $('#update-messages ol').append($('
  • ').text(results.message)); + wcs_ajax_update_products(); + wcs_ajax_update_hooks(); + }, + error: function(results,status,errorThrown){ + wcs_ajax_update_error(); + } + }); + } + function wcs_ajax_update_products(){ + $.ajax({ + url: wcs_update_script_data.ajax_url, + type: 'POST', + data: { + action: 'wcs_upgrade', + upgrade_step: 'products', + nonce: wcs_update_script_data.upgrade_nonce + }, + success: function(results) { + $('#update-messages ol').append($('
  • ').text(results.message)); + }, + error: function(results,status,errorThrown){ + wcs_ajax_update_error(); + } + }); + } + function wcs_ajax_update_hooks() { + var start_time = new Date(); + $.ajax({ + url: wcs_update_script_data.ajax_url, + type: 'POST', + data: { + action: 'wcs_upgrade', + upgrade_step: 'hooks', + nonce: wcs_update_script_data.upgrade_nonce + }, + success: function(results) { + if(results.message){ + var end_time = new Date(), + execution_time = Math.ceil( ( end_time.getTime() - start_time.getTime() ) / 1000 ); + $('#update-messages ol').append($('
  • ').text(results.message.replace('{execution_time}',execution_time))); + } + if( undefined == typeof(results.upgraded_count) || parseInt(results.upgraded_count) <= ( wcs_update_script_data.hooks_per_request - 1 ) ){ + wcs_ajax_update_subscriptions(); + } else { + wcs_ajax_update_hooks(); + } + }, + error: function(results,status,errorThrown){ + wcs_ajax_update_error(); + } + }); + } + function wcs_ajax_update_subscriptions() { + var start_time = new Date(); + + if ( null === upgrade_start_time ) { + upgrade_start_time = start_time; + } + + $.ajax({ + url: wcs_update_script_data.ajax_url, + type: 'POST', + data: { + action: 'wcs_upgrade', + upgrade_step: 'subscriptions', + nonce: wcs_update_script_data.upgrade_nonce + }, + success: function(results) { + if('success'==results.status){ + var end_time = new Date(), + execution_time = Math.ceil( ( end_time.getTime() - start_time.getTime() ) / 1000 ); + + $('#update-messages ol').append($('
  • ').text(results.message.replace('{execution_time}',execution_time))); + + wcs_update_script_data.subscription_count -= results.upgraded_count; + + if( "undefined" === typeof(results.upgraded_count) || parseInt(wcs_update_script_data.subscription_count) <= 0 ) { + wcs_ajax_update_complete(); + } else { + wcs_ajax_update_estimated_time(results.time_message); + wcs_ajax_update_subscriptions(); + } + } else { + wcs_ajax_update_error(results.message); + } + }, + error: function(results,status,errorThrown){ + $('
    Error: ' + results.status + ' ' + errorThrown + '').appendTo('#update-error p'); + wcs_ajax_update_error( $('#update-error p').html() ); + } + }); + } + function wcs_ajax_repair_subscriptions() { + var start_time = new Date(); + + if ( null === upgrade_start_time ) { + upgrade_start_time = start_time; + } + + $.ajax({ + url: wcs_update_script_data.ajax_url, + type: 'POST', + data: { + action: 'wcs_upgrade', + upgrade_step: 'subscription_dates_repair', + nonce: wcs_update_script_data.upgrade_nonce + }, + success: function(results) { + if('success'==results.status){ + var end_time = new Date(), + execution_time = Math.ceil( ( end_time.getTime() - start_time.getTime() ) / 1000 ); + + $('#update-messages ol').append($('
  • ').text(results.message.replace('{execution_time}',execution_time))); + + wcs_update_script_data.subscription_count -= results.repaired_count; + wcs_update_script_data.subscription_count -= results.unrepaired_count; + + if( parseInt(wcs_update_script_data.subscription_count) <= 0 ) { + wcs_ajax_update_complete(); + } else { + wcs_ajax_update_estimated_time(results.time_message); + wcs_ajax_repair_subscriptions(); + } + } else { + wcs_ajax_update_error(results.message); + } + }, + error: function(results,status,errorThrown){ + $('
    Error: ' + results.status + ' ' + errorThrown + '').appendTo('#update-error p'); + wcs_ajax_update_error( $('#update-error p').html() ); + } + }); + } + function wcs_ajax_update_complete() { + $('#update-ajax-loader, #estimated_time').slideUp(function(){ + $('#update-complete').slideDown(); + }); + } + function wcs_ajax_update_error(message) { + message = message || ''; + if ( message.length > 0 ){ + $('#update-error p').html(message); + } + $('#update-ajax-loader, #estimated_time').slideUp(function(){ + $('#update-error').slideDown(); + }); + } + function wcs_ajax_update_estimated_time(message) { + var total_updated = total_subscriptions - wcs_update_script_data.subscription_count, + now = new Date(), + execution_time, + time_per_update, + time_left, + time_left_minutes, + time_left_seconds; + + execution_time = Math.ceil( ( now.getTime() - upgrade_start_time.getTime() ) / 1000 ); + time_per_update = execution_time / total_updated; + + time_left = Math.floor( wcs_update_script_data.subscription_count * time_per_update ); + time_left_minutes = Math.floor( time_left / 60 ); + time_left_seconds = time_left % 60; + + $('#estimated_time').html(message.replace( '{time_left}', time_left_minutes + ":" + zeropad(time_left_seconds) )); + } + function zeropad(number) { + var pad_char = 0, + pad = new Array(3).join(pad_char); + return (pad + number).slice(-pad.length); + } +}); \ No newline at end of file diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..1257550 --- /dev/null +++ b/changelog.txt @@ -0,0 +1,1038 @@ +*** WooCommerce Subscriptions Changelog *** + +2016.06.24 - version 2.0.17 + * Tweak: add support for bulk editing subscription variation prices using relative increase or decrease from existing price (either fixed amount or per cent). (PR#1425) + * Tweak: use filterable helper function when validating switch request to improve compatibilty with Name Your Price and other plugins. (PR#1453) + * Tweak: require payment when switching from $0 / period subscriptions to a non-zero / period subscription if the store is not disabling automatic payments so that future payments on the new subscription can be processed automatically. (PR#1420) + * Tweak: use new WooCommerce 2.6 hook to load the View Subscription template to so the view-subscription.php template does not require addition WC version checks. (PR#1459) + * Tweak: add tooltip to the next payment date displayed on the Subscriptions administration screen to indiciate that the date is only a guide when the payment gateway controls the billing schedule (like PayPal Standard). (PR#1445) + * Fix: make sure PayPal Standard subscriptions are cancelled at PayPal when switching a subscription with PayPal Standard as the payment method but using a different payment method for the switch transaction. (PR#1419) + * Fix: do not generate a renewal order for the PayPal Standard IPN message for $0.01 payments, which is used to get two trial periods on a subscription to align a synchronised payment date, not to actually process a $0.01 payment. (PR#1413) + * Fix: calculate correct next payment date when paying for old failed or manual renewal orders by updating the post dates on the order when the order's status is changed to indicate payment is completed. (PR#1435) + * Fix: do not reduce stock for initial order of synchronised products when no initial payment (like sign-up fee or prorated amount) is charged, as nothing is shipped for that order. (PR#1438) + * Fix: use new WooCommerce 2.6 hook to load the View Subscription template to prevent a PHP notice about deprecated my-account.php template. (PR#1459) + * Fix: provide empty default when accessing chosen shipping methods from the session to fix 'Invalid argument supplied for foreach()' PHP notice when no default shipping method is set. (PR#1456) + * Fix: update subscriptions using PayPal Reference Transactions to "pending-cancelation" status rather than "cancelled" when a PayPal Billing Agreement is cancelled to account for pre-paid time. (PR#1468) + * Fix: escape the style attribute for synchronisation fields on the Edit Product screen correctly to avoid escaping attribute quotes. (PR#1464) + * Fix: do not allow switching using PayPal Standard as the payment method for the switch. (PR#1461) + * Fix: do not unnecessarily copy switch related line item meta data to renewal orders. (PR#1423) + * Fix: display correct order date on hover in the Related Orders meta box. (PR#1442) + +2016.06.10 - version 2.0.16 + * Tweak: display recurring shipping selection fields on cart page instead of notice to select recurring shipping on checkout page (when unique recurring shipping methods are required). (PR#1431) + * Tweak: update cart hash creation for latest logic used in WooCommerce 2.6. (PR#1426) + * Tweak: throw an exception if a scheduled next payment or expiration hook is triggered for a subscription ID that no longer exists (due to a corrupted database). (PR#1429) + * Tweak: add order type parameter to WC_Subscription::get_last_order() so it can be used to return switch orders. (PR#1398) + * Fix: only validate recurring shipping for carts that have future recurring payments. (PR#1440) + * Fix: trigger subscription.created webhook for subscriptions purchased via checkout. (PR#1428) + * Fix: function declaration incompatible with WooCommerce 2.6 release candidate (specifically: WC_Subscription::set_payment_method()). (PR#1434) + * Fix: duplicate My Account navigation with WooCommerce 2.6 release candidate. (PR#1437) + * Fix: incorrect recurring shipping amount being applied to subscription products that do not require recurring shipping if a product with an identical billing schedule that required shipping was added then removed from the cart. (PR#1394) + * Fix: remove unused global variable in WCS_Query. (PR#1424) + * Fix: remove redundant in_the_loop() call in WCS_Query. (PR#1424) + +2016.05.28 - version 2.0.15 + * Tweak: WooCommerce 2.6 Compatibility. + * Tweak: with the upcoming WooCommerce 2.6, add a Subscriptions tab to the My Account tabs and display subscriptions on that page instead of the My Account page, which is now a "Dashboard" in WooCommerce 2.6. (PR#1377) + * Tweak: with the upcoming WooCommerce 2.6, display the new My Account tabs on the My Account > View Subscription page. (PR#1396) + * Tweak: improve display of the My Account > View Subscription page on small screens. (PR#1396) + * Tweak: pass $args param to 'woocommerce_new_subscription_data' filter. (PR#1410) + * Tweak: use pretty date on hover for Subscription admin screen dates. (PR#1416) + * Tweak: remove WooCommerce < 2.3 compatibility code as this versions are no longer supported. (PR#1395) + * Fix: do not attempt to validate recurring shipping methods when no recurring shipping is required, for example, if the subscription is for one payment or using a Subscribe All the Things product. (PR#1404) + * Fix: do not require payment for switches with $0 switching cost unless the subscription was using PayPal Standard as the payment method and when that's the case, update the subscription payment method after the switch payment is processed if the new payment method is not PayPal. (PR#1412) + * Fix: display of related orders meta box on administration Edit Order and Edit Subscription screens with WooCommerce 2.6. (PR#1387) + * Fix: always trigger subscription renewal payment hooks (i.e. 'woocommerce_subscription_renewal_payment_complete' and 'woocommerce_subscription_renewal_payment_failed') for the first renewal payment, even if there was no initial order for the subscription. Previously, the first payment was not considered a renewal payment, even if it was on a renewal order. (PR#1390) + * Fix: prioritise sign-up fee and trial length filters in cart calculations over value set on cart item. (PR#1378) + * Fix: display correct "Total" row heading for the Related Subscriptions table shown on the My Account > View Order page on small screens. (PR#1396) + * Fix: with the upcoming WooCommerce 2.6, do not create a new order instead of completing payment on the renewal order when completing renewal payment via cart/checkout. (PR#1407) + * Fix: with the upcoming WooCommerce 2.6, do not depend on the 'wc-admin-order-meta-boxes-modal' JavaScript, as it no longer exists. (PR#1407) + +2016.05.14 - version 2.0.14 + * Tweak: add automatic install plugin URL for WooCommerce when it's not installed. (PR#1345) + * Tweak: add new 'wcs_pre_get_users_subscriptions' filter for custom caching. (PR#1360) + * Fix: make sure shipping line items are added to subscriptions for subscription purchases where the recurring packages are different to the initial packages (i.e. subscriptions with free trial, sync'd or multiple subscription purchases with different shipping classes) and there is only one shipping method available and a customer had previously chosen a different method front end of the store, but that method has since been disabled. (PR#1319) + * Fix: don't empty the cart if adding a product already in the cart when there are no payment gateways which support multiple subscriptions and manual renewals are disabled. (PR#1347) + * Fix: calculate correct next payment and end date for synchronised subscriptions with a `0` trial period value (i.e. not empty or ''). (PR#1348) + * Fix: set default values for subscriptions dropdown fields when bulk adding variations. (PR#1350) + * Fix: rather than using the trial period and interval to determine when to calculate a product's expiration date from, use the date returned by WC_Subscriptions_Product::get_trial_expiration_date() to honour any 3rd party code attaching to the 'woocommerce_subscriptions_product_trial_expiration_date' hook. (PR#1339) + * Fix: display the correct recurring shipping price when store is using tax inclusive pricing. (PR#1367) + * Fix: only apply recurring percent coupons when there is no free trial. (PR#1365) + * Fix: make sure a recurring shipping method has been selected before allowing checkout for products with a free trial or synchronised. (PR#1358) + * Fix: display shipping addresses on the Order Received page and in order emails for orders that do not require shipping but are to purchase a subscription which does require shipping in future because they have a free trial or are synchronised. (PR#1358) + * Fix: potential fatal error when a customer views the View Subscription page for a subscription with a line item that can be switched but has been deleted. (PR#1376) + +2016.04.22 - version 2.0.13 + * Tweak: make recurring shipping packages globally available by storing them in WC()->shipping->packages. (PR#1294) + * Tweak: add new 'use_per_slash' param in wcs_price_string() function to allow code calling wcs_price_string() to choose whether to use a "/" or longer from "every" for subscriptions which renew each period. (PR#1297) + * Tweak: improve performance of POST requets to /wc-api/subscriptions/ to create a new subscription by only calculating customer's total spent amount once, instead of when creating the order and subscription. (PR#1316) + * Tweak: improve performance when loading the front-end product page for a variable subscription product that is limited for logged in users with a subscription to one of the variations of that product. (PR#1324) + * Fix: compatibility with Local Pickup Plus. (PR#1291 PR#1292 PR#1293 PR#1329) + * Fix: only modify shipping packages when cart contains a subscription. Fixes display of shipping methods selection fields when a customer attempts to pay for a failed or manual renewal order that contains a synced subscription product. (PR#1291) + * Fix: pass correct shipping package key to callbacks on the 'woocommerce_add_shipping_order_item' hook for recurring shipping fields (PR#1292) + * Fix: pass the recurring package key as index value to the cart-recurring-shipping.php template to fix compatibility with shipping extensions that hook into that, namely Local Pickup Plus. (PR#1293) + * Fix: incorrect tax rate applied to recurring totals when using Local Pickup Plus as the shipping method. (PR#1329) + * Fix: incorrect admin email being sent when paying for a failed renewal order. (PR#1310) + * Fix: make sure translations are ready before attempting to use them. Also load plugin text domain on 'init' instead of 'plugins_loaded' to improve compatibility with localisation extension. (PR#1303) + * Fix: display of inventory settings on the Edit Product admin screen when loading or changing to a subscription product type. (PR#1322) + * Fix: ensure sign up fee paid includes the paid tax if tax inclusive so that it is consistent with the sign up fee due amount and the prorated sign-up fee amount is calculated correctly when upgrading/downgrading. (PR#1312) + * Fix: do not discount sign-up fees with recurring coupons. (PR#1325) + * Fix: allow end date for a subscription to be the same as the last payment date. Fixes subscriptions with an abandonded PayPal order being able to be suspended/activated if that pending order is cancelled. (PR#1330) + * Fix: calculate correct end date for synchronised subscription products with a free trial. (PR#1327) + * Fix: prorated upgrade/downgrade amount when switching a synchronised subscription before the first payment date. (PR#1308) + * Fix: set correct number of installments when signing up for a synchronised subscription with an end date at PayPal Standard. (PR#1313) + * Fix: only allow one variation of a limited variable product to be added to the cart to avoid the customer being able to buy multiple variations of a limited product. (PR#1332) + * Fix: "The next_payment date must occur after the trial end date" error notice sometimes dislpayed on checkout when purchasing a synchronised product with a free trial period. (PR#1324) + * Fix: add fees applied to a renewal, initial order or subscription to the cart when paying for renewal, an initial sign-up or resubscribe. (PR#1334) + +2016.03.18 - version 2.0.12 + * Tweak: new 'woocommerce_my_subscriptions_actions' hook in the My Subscriptions table + * Fix: shipping bug when the packages for recurring orders were different to those for the initial order. Fixes compatibility with Table Rate shipping and occasional issues with manual renewals for products with a free trial or synchronised renewal date. Refactoring of shipping selection required to address the issue: shipping method selection fields are now displayed on checkout in the Recurring Totals section when the available recurring shipping rates are different to the rates for the initial order (either because the methods are different, the costs are different, or the package contents is different). + * Fix: error on WooCommerce > Edit Subscription admin screen for subscriptions with recurring coupons caused by attempting to use WC()->session on backend/admin requests + * Fix: search customer/user email address when searching via the WooCommerce > Subscriptions administration screen + * Fix: 404's of the "My Account > View Subscription" page after updating WooCommerce + * Fix: include up-to-date pot file + +2016.03.03 - version 2.0.11 + * Fix: activation issue with v2.0.10 on some sites due to get_editable_roles() function not being defined + * Tweak: don't allow subscribers to cancel subscriptions with pending cancellation status + +2016.02.27 - version 2.0.10 + * Tweak: large improvements to internationalisation of strings after a full audit of all strings made available for localisation. Subscriptions now makes use of string context more often, uses numbered placeholders for strings passed through sprintf/printf, consolidated strings with same context and added more comments for translators. + * Tweak: display details of product, shipping and tax line items at PayPal for subscription sign-ups via Express Checkout Reference Transactions instead of only displaying the billing agreement description. + * Tweak: add new wcs_get_address_type_to_display() method to have a single source for all localised address type display names + * Tweak: a number of small security improvements around validation of printing strings + * Tweak: new 'woocommerce_subscription_use_pending_cancel' filter to allow stores to switch off the use of the Pending Cancellation status applied until the end of any prepaid term on a subscription once it has been cancelled + * Tweak: use wp_json_encode() when possible (with WordPress v4.1 and newer) instead of PHP's json_encode(), the use of which is discouraged + * Tweak: move user role filter to after the default roles section and introduce wcs_get_new_user_role_names() to allow custom code to more easily customise the role assigned to a user for subscription events. + * Fix: Product add-on incompatibility: make sure product add-ons are only applied once for manual renewal payments for a previously failed automatic renewal payment. + * Fix: Checkout Add-ons incompatibility: add subscription fee line items to the cart for manual renewal and resubscribe to make sure Checkout Add-ons are applied to manual renewals and resubscribes + * Fix: compatibility with remote updaters, like WP Remote, Jetpack Manage by activating subscriptions on 'init' not 'admin_init' because remote udpaters won't trigger that hook. + * Fix: apply recurring coupons to manual renewals by making sure they are set in the cart at the time of renewal. Introduces the use of the following new psuedo coupons for carrying over recurring coupons to renewals: 'renewal_fee', 'renewal_percent' & 'renewal_cart'. + * Fix: make sure it is possible to link a subscription with a parent order by passing a 'order_id' argument when creating or updating a subscription via the REST API endpoints (i.e. /wc-api/v4/subscriptions/) + * Fix: don't show synchronisation fields on the Edit Product screen for a subscription that renews daily when toggling the downloadable or virtual checkboxes + * Fix: make sure you can bulk edit variations before a variable product is saved with WooCommerce v2.5 by using the product type field + * Fix: don't incorrectly log the "Free trial commenced for subscription" order note on a subscription when applying coupons to make the initial payment free. Instead, log the more general "Sign-up complete" order note whenever a subscription without an initial payment receives the first payment complete function call. + * Fix: don't allow resubscribing to subscriptions with a $0 / period price, because generally, this is done to charge the full cost of the subscription in the sign-up fee, and we don't charge sign-up fees on a resubscribe. + * Fix: occassional error when updating a subscription via the Edit Subscription screen: "Error updating subscription: A valid PayPal Billing Agreement ID value is required" caused by using disabled payment method meta fields (Subscriptions now uses readonly fields). + * Fix: send cancelled subscription emails to store managers when a subscription is deleted early in the process of deletion to ensure billing meta fields are still available to include in the email. + * Fix: log correct next payment date when processing a PayPal IPN payment request for a subscription that has a payment in the future. Also fixes an undefined index notice for these IPN requests for subscriptions with no free trial. + * Fix: allow disabled payment gateways that are still active as plugins to be treated as a valid payment method when saving a subscription via the Edit Subscription screen to avoid switching the subscription to use Manual renewals. + * Fix: always display order numbers not IDs to improve compatibility with plugins like Sequential Order numbers + * Fix: when a switch order's status is changed, don't cancel, suspend or mark the corresponding subscription as failed. Only do that on those status changes for the original order. + * Fix: display all subscriptions on the Subscriptions list table when filtering by payment method (previously the default posts per page value would be used, which is 5). + * Fix: leap year issues when creating subscription terms with PayPal Standard that can lead to prolonged free trial or subscription duration for subscripions that have an annual free trial or billing period. + * Fix: set correct subscription length options for subscription variations on the Edit Product screen when first loading the page (affects subscriptions with a billing interval other than 1) + * Fix: duplicate PayPal IPN blocking on sites running PHP prior to version 5.4.0. + +2016.01.30 - version 2.0.9 + * Notice: if you have customisations to the /add-to-cart/variable.php template you wish to apply to variable subscription products, you will need to create a new /add-to-cart/variable-subscription.php template in your theme as this version introduces a new template specifically for variable subscription products + * Tweak: display customer as recipient on WooCommerce > Settings > Email screen with WooCommerce 2.5+ + * Tweak: automatically cancel a subscription with the 'pending-cancellation' status if the last renewal order is refunded + * Tweak: flush rewrite rules on activation / deactivation rather than just on upgrade to ensure /view-subscription/ rewrites are always available + * Tweak: use variation ID to test if a subscription's product line item can be switched (helps extensions, like Name Your Price, override if specific variations can be switched) + * Tweak: delete subscriptions on user deletion instead of simply trashing them + * Tweak: new filters for 3rd party developers to use for renewal/resubscribe: 'wcs_cart_contains_renewal', 'wcs_cart_contains_failed_renewal_order_payment', 'wcs_users_resubscribe_link_for_product', 'wcs_cart_contains_resubscribe' + * Tweak: display a notice on limited variable subscription products that can not be switched by introducing a new variable subscription template + * Fix: after the last payment a subscription with an end date, clear the next payment date instead of leaving it as the date of the last payment + * Fix: only display resubscribe notice if a subscription resubscribe was added to cart successfully + * Fix: remove download permissions when product line items are switched to make sure customer only has access to line items of the product they are currently subscribed to + * Fix: when checking whether to apply a discount, only call is_valid_for_product() with WooCommerce 2.5+ because it will always return false in prior versions + * Fix: make sure subscriptions are cancelled before being deleted by using the correct hook name (they were already being correctly cancelled when trashed) + * Fix: do not redirect for auto-switching on limited subscriptions if switching has been disabled in the store + * Fix: always create a failed renewal order for PayPal IPN messages relating to a failure when the subscription is active because PayPal may send the subscription suspended message before the payment failed message and therefore, we'd mark the old renewal order as failed instead of a new renewal order + * Fix: make sure resubscribe orders do not incorrectly charge signup fees after a failed or pending payment on the resubscribe order + * Fix: ignore suspended payment PayPal IPN messages for subscriptions with any status other than 'active' to avoid exceptions when attempting to suspend a subscription pending-cancellation or custom status that can't be suspended + * Fix: display the # symbol before subscription numbers/IDs in all places where WooCommerce displays the # symbol before the order number/ID + * Fix: make sure subscriptions created with PayPal Standard are correctly cancelled when changing the customer changes the payment method to PayPal but the store now supports PayPal Reference Transactions + * Fix: set the correct end date for a synchronised subscription by ensuring that no mock trial period in included in its date + * Fix: make sure correct totals are used for manual renewals (and manual payments for failed automatic renewals) when store is using tax inclusive pricing (by making sure the correct tax amount is added to the renewal item's price) + +2016.01.07 - version 2.0.8 + * Happy new year! + * Tweak: WooCommerce 2.5 email template compatibility + * Tweak: Use "Change Payment Method" as the default button text on the customer facing Change Payment method form instead of the payment gateways's order button text as often gateways will use something like "Place Order". + * Tweak: Move hardcoded # symbols used for Subscription ID inside translation functions so they can be localised. + * Fix: Display PayPal Subscription Profile ID for PayPal Standard Subscriptions on the Edit Subscription administration screen (but disable the input so that it is not editable by default). + * Fix: When changing a subscription's payment method via the Edit Subscription administration screen, make sure the status updated hook is trigger for the old payment method to allow gateways to cancel/suspend it as needed. + * Fix: When changing a subscription's payment method from PayPal to a different gateway via the Edit Subscription administration screen, suspend the existing subscription at PayPal to ensure no future payments are processed on it. + * Fix: Apply recurring cart discount coupons to products not excluded from that coupon even if the cart contains other products which are excluded from that coupon. Requires WooCommerce 2.5. + * Fix: Remove renewal/resubscribe cart items from cart when the renewal/resubscribe order or original subscription has been deleted to prevent errors. + * Fix: Correctly cache a subscriptions payment gateway rather than instantiating it again every time it is accessed for a small performance improvement. + * Fix: Display the View Subscription page when My Account page is set as the site's front page. + * Fix: Allow unlimited subscription suspension by customer from their My Account page + * Fix: Display "Total" row heading for My Subscriptions table on small displays + +2015.12.16 - version 2.0.7 +* New: Add a payment gateway filter to the dashboard admin subscriptions table +* New: Implement soft caching for get_related_orders() +* Tweak: Better "Name Your Price" extension switching compatibility +* Tweak: Trash a user's subscriptions when a user is deleted +* Tweak: Limit the `wcs_do_subscriptions_exist()` query to 1 result +* Tweak: Mention subscription string methods in code comments/doc to help avoid some confusion +* Fix: Fix for limiting and paging with the /subscriptions/ API endpoint +* Fix: Use `esc_url_raw()` for redirect url instead of `esc_url()`. Fixes an issue where the return from paypal displays checkout page again instead of order received/details +* Fix: Replaces `parse_str()` with `wp_parse_str()` to fix an issue with incorrectly escaped responses from PayPal in early PHP versions +* Fix: Prevent renewal orders being cancelled when clicking the "Cancel and return to .." link from PayPal +* Fix: Check whether an order needs payment before processing initial payments. Prevents a difficult to reproduce scenario where initial payments were processed multiple times +* Fix: Check if a cart contains a subscription AND the subtotal is 0 before removing any non subscriptions coupons if there is nothing for the coupon to be applied to. Fixes a compatibility issue with extensions like URL coupons. +* Fix: Fixes some php errors when a switch order fails +* Fix: Removes subscriptions from being calculated in reports. Fixes an issue where failed parent orders were incorrectly showing up in reports +* Fix: Filter needs_payment when processing change payment method requests. Fixes an issue with some payment gateways displaying duplicate fields on the change payment method form +* Fix: Paypal IPN tweaks for slow servers & popular stores - port of existing 1.5.x fixes +* Fix: Add additional cart item data when setting up resubscribe and renewal carts so that subscriptions with multiple line items of the same product don't get merged into the one line item with an incorrect quantity. Fixes an issue where if a subscription had 2 line items for the same product, on resubscribe, one of them would disappear +* Fix: Check if $this->payment_gateway property exists. Fixes an issue when subscriptions used on sites served using HHVM +* Fix: Prevent partially paying for a renewal order if not all renewal items are added to cart +* Fix: Changes the $subscription->needs_payment() function to also include switch order meta in the get get_posts query so the latest switch order or renewal order is checked if it needs payment. Fixes customer self reactivating subscription after prorated switch +* Fix: Zero the subscription order shipping value so that we have a clean slate to add to when we add_shipping() when switching. Fixes incorrect/double shipping order tax when switching +* Fix: Other minor misc spelling fixes and fixes to incorrect variable names + +2015.11.16 - version 2.0.6 + * New: Add a new `woocommerce_subscriptions_switched_item` callback for switching + * Tweak: Move `subscription_expired` and `subscription_end_of_prepaid_term` to the action deprecator + * Tweak: Deprecate WC_Subscriptions_Manager::subscription_trial_end() + * Fix: Add the correct `subscription_switch` meta to all subscription switches when there are multiple switches in the same order + * Fix: Allow autocomplete of orders that have multiple switches in the same order. Fixes an issue where switch emails were not being sent when multiple switched items were processed in the same order + * Fix: Adjust logic around switch label so that switching to $0 per period displays "Downgrade". Fixes an issue where "Crossgrade" was incorrectly displayed when downgrading to $0 per period + * Fix: Don't initiate auto-switch when a subscription needs payment + * Fix: Use the 'wc-pending' status rather than 'pending' (default WordPress) status when manually creating a subscription. Fixes an issue with display of pending subscriptions on the admin subscription list table + * Fix: Replace use of `__DIR__` with `dirname( __FILE__ )` to maintain compatibility with PHP 5.2.4 + * Fix: Fix the logic checking for sync day in future by comparing the full date not just the day then the month/year if that day is after the current day. This ensures we don't see a day in the future with a month and year in the past or same as today as being a date in the future + * Fix: Display the resubscribe relationship in the admin edit order and subscription related orders table + * Fix: Directly access the `$_REQUEST['action']` variables instead of using `$wp_list_table->current_action()`. Fixes an issue where while filtering the admin subscriptions list table by customer, attempting to update a subscription status using the provided suspend, cancel and reactivate actions wouldn't actually update the status + * Fix: Honour previous filters applied to the admin subscription list table after updating an individual subscription status using the provided actions + +2015.11.07 - version 2.0.5 + * Tweak: the "Suspend" button is no longer displayed on a store manager's View Subscription page when 0 suspensions are allowed, to avoid confusion (store managers can always suspend subscriptions from the Manage Subscriptions screen) + * Tweak: use both order status and paid date meta data to find the completed payment count on a subscription because some payment gateways don't set the paid date and some stores may run custom code or 3rd party plugins using a custom order status other than "completed" + * Tweak: add new 'woocommerce_subscriptions_switch_error_message' filter and improve params on 'woocommerce_subscriptions_is_switch_valid' to allow 3rd party code, like Name Your Price, to allow their products to be switched + * Tweak: add new 'woocommerce_subscriptions_paid_order_statuses' filter for 3rd party code to declare what custom order status should be considered as marking an order as paid + * Fix: update the trial, next payment and end dates as well as the start date when changing the status of a order used to purchase a subscription (for stores which use manual payment methods which can take days before payment is received) + * Fix: incorrect tax calculation when resubscribing or manually renewing to a subscription when prices are tax inclusive + * Fix: always setting subscription start date to 1 minute in past when manually adding a subscription via the Edit Subscription screen (by updating the post cache) + * Fix: handling of 'recurring_payment_suspended_due_to_max_failed_payment' and 'recurring_payment_suspended' PayPal IPN requests which send a PayPal Express Checkout IPN payload for PayPal Standard Subscriptions + * Fix: incorrectly removing any non-Subscriptions role from a user when purchasing or deactivating a subscription (by removing and adding only the default role) + * Fix: PHP notice about undefined index 'txn_type' for PayPal IPNs relating to a refund + * Fix: errors relating to an order awaiting payment that has been set to false or deleted instead of being unset in the WooCommerce session + * Fix: incorrect transition of orders (including renewal orders) for $0 amounts to "completed", instead make sure they are transitioned to the default completed status (i.e. "processing") + +2015.11.02 - version 2.0.4 + * Tweak: cache available subscription lengths to improve performance of the WooCommerce > Subscriptions list table page + * Tweak: add safeguards to prevent a next payment within 2 hours when activating a subscription, or a new next payment date be calculated within 2 hours in the future. Works around issues with 3rd party code using date_default_timezone_set() and a daylight savings bug that will be fixed in a future release + * Tweak: add new filters to allow 3rd party extensions to make their product types switchable (specifically to allow Name Your Price products to be switchable without any changes other than price). Props @helgatheviking + * Tweak: display subscription details on customer facing change payment method process for gateways which use the Checkout > Pay page a 2nd time + * Tweak: when deleting a user, make a note on the subscriptions that user was associated with (which will automatically be cancelled) + * Fix: accumulated proration of sign-up fees when the customer switches a subscription multiple times (e.g. when sign-up fee proration is enabled and a customer upgrade from a product with a $100 fee to a $200 fee, then again to a $300 fee, only charge a $100 sign-up fee, not $200) + * Fix: don't suspend then reactivate subscriptions at PayPal on a renewal (introduced with 2.0.3) because store manager don't like the extra status change entries logged at PayPal and also PayPal doesn't sent a Subscription reactivated IPN, but does send a Subscription suspended IPN, which could lead to subscriptions remaining on-hold incorrectly if IPN handling code for PayPal Express Checkout/Digital Goods was handling it + * Fix: customer facing change payment method process for gateways which need to use the Checkout > Pay page a 2nd time, for example, to collect credit card details + * Fix: JavaScript pop-up notices relatd to invalid billing schedule modifications on the Edit Subscription screen, broken with v2.0.3 + * Fix: don't attempt to cancel an expired, trashed or cancelled subscription when deleting a user (the subscription has already been deleted) + * Fix: don't record failed payment twice on a subscription with code calling WC_Subscription->payment_failed() with an unpaid renewal order + * Fix: handling of PayPal IPN for first payment that is sent before the payment is due for a subscription with a free trial date + * Fix: handling of PayPal IPN for subscription failure for a subscription has has previously been cancelled (not sure why PayPal sends this IPN when the subscriptoin has already been cancelled) + * Fix: don't send Customer Invoice email when using the the "Create Pending Renewal Order" action from the Edit Subscription screen for a subscription that uses manual renewals + * Fix: don't automatically complete a pending renewal order for $0 when using the "Create Pending Renewal Order" action from the Edit Subscription screen + * Fix: fix PHP notices dislpayed when removing multiple cart items from the cart which relate to a failed initial payment + * Fix: don't prorate sign-up fees for synchronised subscriptions when proration is enabled for the first renewal payment + * Fix: allow customers to pay failed parent orders which contain a subscription product limited to one of any status + * Fix: fatal errors when 3rd party code is calling WC_Subscriptions_Product::is_subscription() on a deleted product + * Fix: don't generate subscription renewal order for subsc_failed PayPal IPN messages + * Fix: increase PayPal IPN logging to help diagnose issues faster + * Fix: download links in the "download your files" email + +2015.10.27 - version 2.0.3 + * New: One Time Shipping feature: https://docs.woothemes.com/document/subscriptions/store-manager-guide/#one-time-shipping + * Tweak: redirect to View Subscription page after changing payment method for payment methods redirecting back to My Account page + * Fix: reactivation of subscriptions using PayPal Standard as the payment method after a subscription renewal payment IPN message is sent (introduced with 2.0.2) + * Fix: use of start date in based on current GMT/UTC offset instead of GMT/UTC offset at the time the subscription was created to handle daylight savings time and changes to a site's timezone + * Fix: notices when saving a manually added subscription with the pending status via the WooCommerce > Add Subscription administration interface + * Fix: increase specificity of JS selector on Edit Product screen to make sure subscription pricing fields are only inserted once, even when other code is adding additional pricing sections + * Fix: return estimated subscription length instead of null for 3rd party code calling deprecated API functions + * Fix: always use WC_API_Subscriptions->get_subscription() to construct the API response for WC-API endpoints + +2015.10.21 - version 2.0.2 + * New: create pending renewal order action from the Edit Subscriptions screen + * New: process renewal action from the Edit Subscriptions screen + * New: 'woocommerce_subscriptions_validate_coupon_type' filter to improve compatibility with Free Gift Coupons extension + * Tweak: display HTTP errors that occur during upgrade + * Tweak: always allow a store manager to change a subscription to use "Manual" renewal method on the Edit Subscription screen, even if manual renewals are not enabled for checkout + * Fix: repair data not migrated correctly from Subscriptions 1.n, especially important for repairing incorrect dates for stores that upgraded with 2.0.0. Data repaired is dates, customer notes and line tax data. + * Fix: repair subscrpitions incorrectly expired due to incorrect expirate date set during the 2.0.0 upgrade process + * Fix: bug in upgrade process preventing line tax date being copied correctly + * Fix: bug in upgrade process preventing customer notes on subscription orders being copied to subscriptions + * Fix: bug in upgrade process where incorrect dates could be set on subscriptions (only affecting upgrades run with 2.0.0 not 2.0.1 and dates will be repaired on upgrade to this version) + * Fix: infinite loop during upgrade process if store had subscription order item meta data but the corresponding order item had been deleted + * Fix: call deprecated status change hooks: 'activated_subscription', 'suspended_subscription', 'subscription_put_on-hold', 'cancelled_subscription' and 'reactivated_subscription' to improve compatibility with plugins that have not been updated for Subscriptions 2.0 yet + * Fix: hide refund button on Edit Subscriptions screen: subscriptions can not be refunded, only orders + * Fix: handling of PayPal IPN recurring_payment_suspended_due_to_max_failed_payment messages - correctly create renewal order on failure instead of simply suspending the subscription + * Fix: occassional "Invalid argument supplied for foreach" notices on cart page when attempting to get shipping methods + * Fix: do not show "Free!" for grouped products with a free trial on the grouped product's page when product is in the cart + * Fix: paying for renewal of a limited subscription product + * Fix: customer changing payment method with PayPal RT + * Fix: [subscriptions] shortcode + +2015.10.09 - version 2.0.1 + * Tweak: add a log entry when processing an IPN request completes + * Fix: PayPal IPN handling for subscriptions purchased with older versions of WooCommerce + * Fix: renewal failure on stores running code attached to the deprecated 'woocommerce_subscriptions_renewal_order_created' hook and not returning the renewal order (which wasn't required previously because it was an action) + * Fix: checkout errors on stores running code calling the deprecated WC_Subscriptions_Order::get_recurring_total_tax() method + * Fix: upgrade error when upgrading a sync'd subscription that has been trashed for a product that has been permanently deleted + +2015.10.05 - version 2.0.0 + * New: purchase different subscription products in the same transaction: https://docs.woothemes.com/document/subscriptions/version-2/#section-2 + * New: administration interface for Adding or Editing a subscription: https://docs.woothemes.com/document/subscriptions/version-2/#section-3 + * New: downloadable content dripping: https://docs.woothemes.com/document/subscriptions/version-2/#section-4 + * New: customer facing View Subscription page: https://docs.woothemes.com/document/subscriptions/version-2/#section-5 + * New: support for PayPal Reference Transactions: https://docs.woothemes.com/document/subscriptions/version-2/#section-8 + * New: Pending Cancellation status applied to a subscription after it has been cancelled but the customer or store manager until the prepaid term ends: https://docs.woothemes.com/document/subscriptions/version-2/#pending-cancellation + * Tweak: Subscriptions administration list table now includes recurring total, payment method and all search/sorting features for stores with a large number of subscriptions: https://docs.woothemes.com/document/subscriptions/version-2/#list-table + * Tweak: Improved flow on renewal - create renewal orders before processing the payment: https://docs.woothemes.com/document/subscriptions/version-2/#section-7 + * Tweak: one end date is now used to refer to the date on which a subscription did or will expire or was cancelled: https://docs.woothemes.com/document/subscriptions/version-2/#one-end-date + * Tweak: the renewal of a cancelled or expired subscription is now called "Resubscribe" to avoid confusion with normal renewal process: https://docs.woothemes.com/document/subscriptions/version-2/#resubscribe-not-renew + +2015.09.29 - version 1.5.31 + * Tweak: introduce a new transient lock at the start of PayPal IPN handling to prevent duplicate IPN handling on sites taking more than a minute to process an IPN message and set the permanent lock + * Fix: handling of PayPal's 'recurring_payment_suspended_due_to_max_failed_payment' IPN message for subscriptions purchased with PayPal Standard + * Fix: infinite loop and memory exhaustion when attempting to switch a limited subscription product + +2015.08.28 - version 1.5.30 + * Fix: redundant pricing elements in subtotal and cart widget totals + * Fix: never given an additional free trials when renewing a cancelled subscription + * Fix: always set an end date for the a switched subscription + * Fix: don't copy over download permissions meta from initial order to renewal order/s + +2015.07.28 - version 1.5.29 + * Tweak: WooCommerce v2.4 compatibility + * Tweak: improve Javascript show/hide field logic on Edit Product to make it easier for 3rd party extensions to work with Subscriptions + * Fix: first renewal of synchronised subscriptions that are purchased within 23 hours of the first renewal payment being due + * Fix: fix cart calculations thrown off by a simple product that used to be a subscription + * Fix: copy multiple meta data values for the same key to renewal orders + * Fix: restrict switching if force http setting is enabled in WooCommerce as moving from https to http will log the user out. Switching relies on an ownership check, which will always fail, and will strip the query arguments from switching making people sign up for new subscriptions instead of switching the one they already have. + * Fix: manual payment flag being incorrect set on an order initial paid for via a manual payment method, then changed to "Pending" and paid for via a payment method that can process automatic renewals + +2015.06.10 - version 1.5.28 + * Fix: add compatibility for JSON encoded data in the 'custom' field passed to PayPal being changed in WC 2.3.11 to fix an exploit with serialized data + * Fix: enforce an array with only two values and no objects in the 'custom' field passed to PayPal to fix an exploit with serialized data + * Fix: only apply the price HTML string if there is a price (i.e. not 0 or empty) + +2015.06.01 - version 1.5.27 + * Fix: make sure subscriptions that were manual activated and do not have a corresponding next payment date can not be switched (i.e. upgraded/downgraded) to ensure proration calculations are correct + * Fix: make sure the next payment date is calculated properly for variable synced subscriptions. + +2015.05.25 - version 1.5.26 + * Tweak: remove reference to "free trial" in coupon error message when attempting to apply a product, cart or sign-up fee coupon to a a synchronised subscription with no initial payment but no free trial + * Tweak: Use WordPress's SSL verification settings when communicating with PayPal to change a subscription's status to improve compatibility with Windows and Yosemite OS X servers + * Fix: make sure coupon error message is displayed when attempting to apply product, cart or sign-up fee coupon to a synchronised subscription with no initial payment + * Fix: fix auto-switch process for subscriptions products which are grouped and limited + * Fix: properly use offset value in $args array sent to WC_Subscriptions::get_subscriptions() + * Fix: PayPal: set a flag in cases where IPN message arrives before PDT redirect happens so first renewal IPN messages aren't ignored in this corner case + +2015.04.27 - version 1.5.25 + * Fix: WordPress 4.2 compatibility issue with post statuses on the Manage Subscriptions screen + * Fix: incorrectly clearing cache by Action Scheduler + +2015.04.21 - version 1.5.24 + * Fix: incorrect renewal order created when a customer switches with PayPal, the switch includes a gap payment and PayPal sends the 'subscr_signup' IPN before the 'subscr_payment' (which only appears to happen on some PayPal accounts) + * Fix: variable subscription product's "From:" price displayed on the shop/product pages. + * Fix: order totals for renewal order created after a subscription was manually reactivated by a store manager after a failed payment left it on-hold + * Fix: only variables should be passed by reference error on some PHP installations when purchasing with PayPal + * Fix: undefined index 'txn_id' notice on PayPal IPN handler for subscription sign-ups on a switch or subscription with a free trial + * Fix: make sure when initiating auto-switch from a limited subscription product's page that the newest subscription is switched (not the oldest, in case the customer has multilpe subscriptions to the same limited product) + +2015.04.21 - version 1.5.23 +* Fix - Potential XSS with add_query_arg + +2015.04.10 - version 1.5.22 + * Fix: use of $this in static context error introduced with v1.5.21 + +2015.04.09 - version 1.5.21 + * Fix: notice displayed on limited subscription products that are part of a Group when not logged in + * Fix: notice displayed on limited subscription products when switching is disabled completely + * Fix: upgrade process on some servers where site is running on SSL but $_SERVER['HTTPS'] is not set and server port is something other than the default SSL port (443), causing WordPress' is_ssl() method to return false (this is fixed by WooCommerce's fix_server_vars() method, but that runs on the 'after_setup_theme' hook, while Subscriptions used 'plugins_loaded' for the upgrade process, which occured prior to it) + * Fix: correctly display the discount string on the Order Received page when a recurring coupon is applied to the purchase. + * Fix: proration calculations for switching costs when store sets prices tax inclusive and displays prices including tax + * Fix: proration calculations for switching costs when customer had or is purchasing multiple quantities of a subscription + +2015.03.13 - version 1.5.20 + * Fix: shipping line tax row display for renewal orders by ensuring that taxes are saved for recurring shipping items or derived from the tax on the shipping line items of the original order if no recurring shipping items are present (for backward compatibility) + +2015.03.03 - version 1.5.19 + * Tweak: make sure get_current_screen() function exists to fix compatibility with any extensions that trigger the 'woocommerce_get_formatted_order_total' hook before it is loaded + * Tweak: allowing recurring cart coupons to be applied to renewal of an expired of cancelled subscription (which creates a new subscription) + * Tweak: support recurring line tax data rows + * Fix: do not allow a limited subscription to be switched from the single product page when switching is disabled on the site or not enabled for that product type + * Fix: after adding a subscription product to the cart, if the customer would not be redirected to checkout or the cart page, redirect them to whichever page they added the product from to ensure the cart widget loads correctly + * Fix: cart widget with WC 2.3.2+ when a subscription product is in the cart with a $0 recurring total + * Fix: line tax row display for automatic renewals orders by ensuring that any serialized line item meta data is unserialized before adding it to renewal orders, to avoid it being serialized twice + * Fix: applying cart or product discount coupon to subscription with $0 in WooCommerce 2.3 via the Checkout page + +2015.02.20 - version 1.5.18 + * Tweak: Improve compatibility with Dynamic Pricing (by using each subscription product object's get_price_html() method to build the price string instead of the generic filters) + * Fix: only apply product and cart coupons to the first payment on a subscription with WooCommerce 2.3 + * Fix: correctly set schedule for subscriptions with an expiration date as soon as it is purchased rather than waiting until the next payment date is displayed + * Fix: generate renewal orders for PayPal subscriptions created to fix up a subscription that was suspended previously due to a failed payment + +2015.02.02 - version 1.5.17 + * WooCommerce 2.3 Compatibility + * Tweak: If the initial order for a synchronised subscription is for $0, mark it as complete immediately as nothing needs to be shipped + * Tweak: Slight improvement to responsive My Subscriptions table based on WooCommerce 2.3 styles + * Tweak: do not allow a cancelled or expired subscription with a $0 recurring total to be renewed as these are most commonly used to charge the total of a subscription up-front in the sign-up fee and renewals of cancelled/expired subscriptions do not charge the sign-up fee + * Fix: allow Subscriptions Core to handle renewal orders with a recurring order amount of $0. This means that the Payment Gateways only be involved in transactions that exceed the $0 amount. + * Fix: proration of recurring amount when subscription product has Product Addons - use total cart item's price (including add-ons) for new price rather than product's price (which doesn't include add-ons) + * Fix: allow subscriptions for 1 payment that also have a free trial to be cancelled during the free trial (i.e. before the first payment) + * Fix: first payment date for annual or monthly synchronised subscriptions purchased in the same month that the first payment is due - a bug introduced in 1.5.16 + +2015.01.09 - version 1.5.16 + * Tweak: only auto-complete subscription switch orders if their total is $0 (i.e. there is no prorating on a physical product) + * Tweak: improve notice regarding purchase of different subscription products in the same transaction to avoid confusion relating to multiple quantity of the same subscription product + * Fix: process first payment for a monthly subscription synchronised to the day of sign up on that day, not one month after sign-up - a bug introduced in 1.5.15 + * Fix: set first payment date to end of trial end date when manually adding a pending subscription with a free trial which the customer then pays for a few days after it was manually added + * Fix: simple subscription product compatibility with the WooCommerce One Page Checkout extension's Single Product template changes introduced in v1.1 + +2014.12.06 - version 1.5.15 + * Tweak: include order item names in item name sent to PayPal when purchasing products with a subscription + * Fix: decode HTML entities in order item names sent to PayPal + * Fix: synchronised renewal date when synhronising a monthly subscription and the next payment date is in the next year + * Fix: only change payment method to PayPal which switching from another payment method to PayPal after the IPN has sent a successful subscription signup notification + +2014.11.27 - version 1.5.14 + * Tweak: better PayPal logging when unable to change subscription status - include profile ID, transaction ID or IPN ID + * Tweak: set PayPal PDT flag when manually adding a subscription via the Edit Order interface so that it doesn't need to be set manually in order for first payment to be processed + * Tweak: don't display free shipping coupon discount amounts as "-$0 / month" + * Fix: reactivating a subscription after customer has completed payment for a failed renewal order via PayPal and the failure was on the first renewal and the store was using PDT at the time of the renewal payment, but was not at the time the subscription was first created + * Fix: scheduling first payment date for synchronised subscriptions purchased on the same day they are synchronised too + +2014.11.10 - version 1.5.13 + * Fix: do not allow customers to switch a subscription where the original order has not yet been paid. + * Fix: do not return cart subscription details, like trial or length, with WooCommerce 2.2 when the cart contains a non-subscription product that was previously a subscription product + * Fix: manual tax calculation from Edit Order screen with WC 2.2 when a shipping and billing address has not yet been set, but store is set to calculate taxes from store's base country + * Fix: renew button display on subscriber's My Account page + * Fix: occasional "Warning: strpos(): Empty delimiter in" notice on Orders screen + * Fix: first payment date (and therefore, payment schedule) when switching subscriptions with PayPal as the payment method and not prorating payment + * Fix: update Action Scheduler to prevent actions with corrupted args (post_content) blocking execution of subsequent actions + * Fix: synchronise renewal dates to 3am instead of midnight to account for any changes to the time if the site is set to use a timezone with daylight savings (e.g. Los Angeles instead of UTC-7) + +2014.10.10 - version 1.5.12 + * Tweak: add 'customer_changed_subscription_to_suspended' and 'customer_changed_subscription_to_cancelled' hooks + * Tweak: add 'admin_changed_subscription_to_on-hold', 'admin_changed_subscription_to_cancelled' and 'admin_changed_subscription_to_active' hooks + * Tweak: rather than deleting shipping on initial order when there is a free trial, set it to $0.00 to improve compatibility with extensions like ShipStation + * Tweak: improve performance of SQL when checking if a subscription can be renewed (as used on My Account page) + * Tweak: add transaction ID to order used to create a subscription + * Fix: switching between simple subscriptions in a grouped product + * Fix: set renewal order status to 'publish' in WC < 2.2 to improve compatibility with extensions, like ShipStation, on sites still running out-of-date WooCommerce + * Fix: handle payment method updates for payment gateway extensions that do not post directly from checkout/pay page. + * Fix: handle PayPal IPN messages for the 'recurring_payment_suspended_due_to_max_failed_payment' notification when both the PayPal Digital Goods extension or another Express Checkout extension and Subscriptions are active on the site + * Fix: make WC_Subscriptions_Renewal_Order::get_renewal_orders() return orders in ascending order of date, also fixes "Pay" button on subscriptions for some sites + * Fix: saving order item recurring tax totals on Edit Order screen when calculating taxes for a manually added subscription item + * Fix: warning about non-static method WC_Subscriptions_Synchroniser::is_today() && WC_Subscriptions_Cart::get_formatted_cart_total() + * Fix: when the cart contains a manual renewal for a synchronised subscription, do not treat it as a synchronised subscription (and display display prorated price label etc.) + * Fix: when manually making a payment to correct a failed renewal order payment with PayPal, create a new subscription with PayPal rather than a simple one-off payment + * Fix: bug in WC_Subscriptions_Cart::get_formatted_cart_total() displaying price with recurring tax added twice when prices include tax + * Fix: bug where customer's order count and total spent was tripled for initial subscription orders + * Fix: incorrect $0 initial payment when using a free trial and/or synchronised subscription and sign up fee equal to recurring total + * Fix: display of checkbox for updating all subscriptions' billing/shipping addresses with WooCommerce 2.2 + +2014.09.08 - version 1.5.11 + * Fix: cart subtotal displayed in the cart widget and anywhere else populated via ajax (i.e. WC_Ajax::get_refreshed_fragments()) + * Fix: "From:" price for a variable subscription where the lowest priced variation is on sale + * Fix: fatal error on My Account page when a subscription's meta data was not correctly saved in the order item meta due to an incorrect reference to a non-existent class name + +2014.09.07 - version 1.5.10 + * Tweak: Full WooCommerce 2.2 Compatibility + * Tweak: link to Checkout/Pay page in the email for a failed automatic payment instead of My Account page + * Tweak: store recurring discount coupons and amounts for display in Subscriptions 2.0 + * Tweak: no longer automatically cancel subscription when initial order status is changed to refunded to allow the subscription to continue even if the initial amount needs to be refunded (particularly important with WC 2.2) + * Tweak: display PayPal API credential admin notice even when PayPal is not in debug mode + * Tweak: reword upgrade/downgrade "apportion" settings to the more semantically accurate "prorate" - props @chrislema + * Fix: only synchronise products when synching is enabled on the store, even if syncing was enabled and the product was setup to sync but synching has since been disabled + * Fix: set correct cart contents count, weight, total and tax when cart contains a subscription + * Fix: require payment when selling a subscription for 1 billing period that has a free trial or is synchronised to a date in the future + * Fix: when manually adding a subscription, set the initial recurring price to exclude tax (to match the way a price is added for standard products) + * Fix: when calculating taxes on the Edit Order screen, set the correct _recurring_line_subtotal_tax and _recurring_line_tax values + * Fix: first payment date of synchronised subscriptions purchased with PayPal + +2014.08.14 - version 1.5.9 + * Tweak: allow switching from one catch all variation to another, as long as the chosen attributes differ between the old subscription and the new one + * Tweak: when renewing an expired/cancelled subscription, do not apply original discounts and coupons - require new coupons to be applied if a discount is being offered + * Fix: show $0 initial amount on cart totals when applying a product, cart or sign-up fee coupon to a subscription that does not have a free trial + * Fix: only apply subscription total calculations to parent renewal orders, not child renewal orders. This prevents a free trial being added to manual renewal orders. + * Fix: copy the attributes of a catch all variation (i.e. attribute = "any") to renewal orders + * Fix: extra tax rows displayed by the order details template when tax totals are displayed as a single total + * Fix: do not allow a new subscription and subscription renewal to be purchased in the cart at the same time (until 2.0 where multiple subscriptions are supported) + * Fix: synchronise subscriptions manually added to an order and make sure line total accounts for whether the subscription is synchronised + +2014.08.01 - version 1.5.8 + * Tweak: improve pointers for new installations + * Tweak: display product/subscription removed notices with blue informational style instead of red error style + * Tweak: when Mixed Checkout is enabled, honour WooCommerce's "Redirect to cart page" setting when adding a subscription to a cart which already contains a subscription (and therefore, is removed) + * Tweak: if a store manager has manually created a subscription to a limited product but not set a user account on that subscription, make sure other non-logged in users can still purchase it (i.e. don't limit "Guest" users) + * Tweak: display an admin notice if PayPal Credentials seem incorrect - PayPal provide no way to verify credentials but if an API request fails due to "Security header is not valid" it's most likely due to incorrect credentials + * Tweak: display an admin notice if PayPal Profile ID's are using the pre-2009 S- prefix (and therefore, will not work with the new API) + * Fix: incorrect line totals with non-period decimal separator when manually adding a subscription with a sign-up fee + * Fix: display correct order totals when purchasing a subscription synchronised to every n weeks + * Fix: do not include up-front shipping on shipping option price strings when the cart only contains a synchronised subscription with a first payment date other than the day of sign-up + * Fix: display sign up fee with tax when the shop's base country is a taxable country, prices are entered excluding tax but prices should be displayed including tax + * Fix: the "From:" price on a variable subscription when using tax inclusive pricing and prices in the shop are displayed including tax + * Fix: display Action Scheduler's admin page when WP_DEBUG is set + * Fix: pre-tax recurring % coupon and sign-up fee % coupon discount amounts displayed on cart and checkout totals when purchasing more than one of the same subscription + * Fix: only trigger gateway scheduled subscription payment hook when a recurring amount needs to be charged - avoids duplicate renewal orders with some gateways like Stripe which generate a failed renewal order when the amount to charge is $0. + * Fix: do not display switch messages on order received page when paying with PayPal + * Fix: subscriptions synchronised to a week day on a site using a non-English locale + * Fix: display of price string for subscriptions synchronised to Sunday each week + +2014.07.15 - version 1.5.7 + * Tweak: do not display "product deleted" error message when manually renewing a simple subscription that has since been changed to a variable subscription + * Tweak: when an order to switch a subscription also contains non-subscription items, do not automatically transition it to completed as those items may need shipping + * Fix: update Action Scheduler to work around issues when using ALTERNATE_WP_CRON + * Fix: format recurring order total using WC 2.1 API functions to handle non-period decimal separators (e.g. ',') + * Fix: initial payment on synchronised subscriptions when there is no sign-up fee + * Fix: allow product and cart coupons to be applied to the cart when it contains products and a subscription with a free trial + +2014.07.04 - version 1.5.6 + * Tweak: add support for switching subscription products that include Product Addons + * Tweak: when calculating next payment date, add additional safeguard against calculating a date for a subscription for 1 billing period + * Fix: when a manual renewal order has been automatically cancelled by WooCommerce, do not allow the customer to reactive the associated subscription from their My Account page + * Fix: compatibility with WooCommerce Additional Fees plugin + * Fix: format renewal order tax totals using WC 2.1 API functions to handle non-period decimal separators (e.g. ',') + +2014.06.24 - version 1.5.5 + * Fix: handle the PayPal Express Checkout 'recurring_payment_suspended_due_to_max_failed_payment' IPN message because PayPal sends it for subscriptions created with PayPal Standard in lieu of any other payment failure notification. + * Fix: synchronised payment date displayed on an order or the cart's totals for subscriptions that have no sign-up fee and are synchronised to the last day of the month (i.e. display "on last day of each month" not "on the 28th of each month"). + * Fix: first payment date for synchronised subscription variations + * Fix: recurring & sign-up fee coupons + * Fix: only apply recurring % discount coupons to recurring component of initial payment (i.e. do not apply the % discount to simple/variable products too) + * Fix: manual subscription renewals on subscriptions limited to "any status" + * Fix: do not add Product Addons prices twice when renewing an expired or cancelled subscription + +2014.06.12 - version 1.5.4 + * New: 'processed_subscription_renewal_payment' and 'woocommerce_renewal_order_payment_complete' hooks + * Tweak: allow store managers to limit a subscription to only one active subscription rather than just limiting regardless of status + * Tweak: improve shipping price displayed in the order totals table after checkout and via the My Account page when the order includes recurring shipping + * Tweak: when changing payment method, log the payment method's ID if its title is empty + * Tweak: show the "Renew" button on a product's page if a customer has a limited subscription that is cancelled or expired + * Tweak: if a customer has an active subscription to a subscription product limited to one per customer, display a notice on the product's page + * Tweak: do not display limited subscription's "product removed from cart" notice when on checkout complete page for PayPal + * Tweak: accept a status of "any" in the WC_Subscriptions_Manager::user_has_subscription() function + * Tweak: group subscription pricing options in the Bulk Edit select box on the Edit Product screen for variable products + * Fix: if using a blank payment method title, display "" on the Edit Order screen, not "Manual" + * Fix: allow subscription products with an active subscription to be restored from the trash (but not permanently deleted) + * Fix: display recurring totals correctly on the Edit Order screen on sites using a locale with a decimal separator other than '.' (e.g. ',') + * Fix: do not carry switch related meta data to renewal orders - prevents renewal orders being marked as completed immediately + * Fix: do not charge shipping up-front when purchasing a virtual non-subscription product in the same transaction as a physical subscription product + * Fix: split recurring shipping taxes are split from recurring sales tax + * Fix: switching subscriptions using a payment gateway which does not call the payment complete function + * Fix: send correct initial payment to PayPal when purchasing products at the same time as a subscription that has a free trial + * Fix: MySQL notice on Manage Subscriptions screen on servers with MySQL 5.6 or newer + * Fix: only allow subscription renewal or switching of limited subscriptions when cart contains matching product ID + +2014.05.28 - version 1.5.3 + * Tweak: add classes to synchronised subscription product and order first payment dates so they can be hidden or styled + * Tweak: let WC Core JS handle show/hide of a simple subscription's pricing fields on the Edit Product page and add 'woocommerce_subscription_product_types' filter to make it possible for other product types to use these fields + * Tweak: pass full subscription array and order item to callbacks on the 'subscription_deleted' hook so callbacks can access the subscription's details + * Fix: apportion totals correctly when upgrading a subscription that is for 1 billing period (e.g. "$19 for 1 month" not "$19 / month") or in the last billing period for its term (e.g. month 12 of a "$19 / month for 12 months" subscription) + * Fix: charge shipping if purchasing physical products with a subscription even if there is a free trial + * Fix: display correct shipping method labels when recurring shipping price differs to initial shipping price for that method + * Fix: do not allow subscription products to be deleted when they have been purchased and the site is using a custom database prefix by fixing use of a hardcoded prefix in a query to count the active subscriptions for a product + * Fix: if a subscription is synchronised to the 27th day of the month, synchronise payments to that day not the last day of the month. + * Fix: send correct initial payment (sign-up fee) to PayPal when purchasing a synchronised subscription where the sign-up fee is the same as the recurring amount + * Fix: if a subscription was purchased with standard products, do not include the totals of the standard products in the sign-up fee value for the subscription + * Fix: add workaround for MySQL error "The SELECT would examine more than MAX_JOIN_SIZE rows" that occurs on some hosts (when subscriptions become a custom post type in v2.0 it will more effectively address the error) + +2014.05.19 - version 1.5.2 + * Tweak: if a non-supported version of WooCommerce is active (i.e. a version prior to 2.1), do not load Subscriptions and display a notice to update WooCommerce + * Tweak: add update paths from Subscriptions version 1.1, 1.2 and 1.3 to version 1.5 (previously only version 1.4 to 1.5 was supported) + * Tweak: improve the notice displayed on checkout when no gateways are available that support subscriptions + * Tweak: store a record of the version installed before running an update + * Tweak: make the row titles on the responsive My Subscriptions table translatable (by adding a data attribute on the table cells and passing it to the CSS psuedo element's content) + * Fix: update .pot file for translation + * Fix: do not charge shipping up-front on subscriptions with a free trial or synchronised to a date in the future + * Fix: do not charge shipping on recurring items when purchasing a virtual subscription with physical products + * Fix: display correct shipping total when no recurring shipping is being charged + * Fix: make sure the variable subscription product type is registered on new installs + * Fix: IPN handling when purchasing synchronised subscriptions with PayPal + * Fix: invalid callback error in WC_PayPal_Standard_Subscriptions - not affecting any functionality + * Fix: numeral suffix for numbers higher than 20 (e.g. 21st not 21th) + +2014.05.13 - version 1.5.1 + * Tweak: add span element around subscription price strings for custom pricing + * Tweak: don't create redundant scheduled action when successfully processing an automatic scheduled renewal + * Fix: prevent a switch request from being processed by or being added to the cart of a customer who does not own the original subscription + * Fix: when saving a Variable Subscription product before variations are added, don't throw a "Warning: array_keys(): The first argument should be an array" notice. + * Fix: make sure the "Used for variations" checkbox is visible when adding a custom attribute to a new Variable Subscription product + +2014.05.05 - version 1.5 + * Requires: WooCommerce 2.1 or newer and Subscriptions 1.4 or newer (if upgrading) + * New: a subscription can now be purchased in the same transaction as other products. + * New: subscriptions can be purchased in multiple quantities rather than having to be purchased individually + * New: subscription renewal dates can now be synced to a specific day of the week, month or year + * New: support for the Points & Rewards extension: points will now be rewarded for subscription renewals + * New: completely new scheduler capable of scaling to hundreds of thousands of subscriptions & processing queues of thousands of simultaneous renewals + * New: filter orders on the Orders administration screen to show only renewal orders (for any subscription) or only original orders i.e. those orders that are not automatically generated to record a subscription renewal + * New: subscription switched email to notify store managers when a customer upgrades/downgrades their subscription (replaces the standard WooCommerce New Order email for subscription switches) + * New: subscription switch email sent to customers when upgrading or downgrading a subscription (replaces the standard WooCommerce Order Complete and Processing Order emails for subscription switches) + * New: added a filter for customising column content on the Manage Subscriptions administration screen (filter's hook name is 'woocommerce_subscriptions_list_table_column_content') + * Tweak: the order created to record a subscription switch is now transitioned to "completed" status regardless of whether the subscription needs to be switched or not because the order is simply a record of the switch and not indicative of an item needing to be shipped + * Tweak: the My Subscriptions section of the My Account page is now responsive + * Tweak: move sign-up fee field to own row on the Edit Product screen to improve display on small screens + * Tweak: when asked what type of product they are, variable subscriptions and subscription variations will now identify as both the subscription and standard equivalent (e.g. if a variable subscription is asked if it is a variable product, it will say yes, if a subscription variation is asked if it is a standard variation, it will say yes). + * Tweak: in API responses, include variable subscriptions' children & subscription variations' parent objects (due to tweak in variable/variation self-identification above) + * Tweak: limit subscription regardless of status to prevent the same customer account effectively being able to access more than one free trial period. More: http://docs.woothemes.com/document/subscriptions/store-manager-guide/#limit-subscription + * Tweak: only allow customers to purchase subscriptions with PayPal once the store's API credentials are entered + * Tweak: prefill "Change" next payment date fields with date in site's timezone instead of next payment date in GMT/UTC timezone + * Fix: fix stock management for variable subscriptions (due to the tweak above making variable subscriptions identify as variable products) + * Fix: edit product links on Reports page for subscription variations (due to the tweak above making subscription variations identify as standard variations) + * Fix: assorted fixes for data returned by API about subscriptions products (due to the tweak above making subscription variations identify as standard variations) + * Fix: product meta section is only printed on the My Subscriptions table if the product has meta data (i.e. don't include an empty
    ) + * Fix: when changing the customer on an order, make sure that all payment, trial & expiration dates are updated to prevent scheduling duplicate hooks (and potentially causing duplicate payments) + * Fix: display correct cart subtotal in cart widget to customer that has not yet visited the cart or checkout page (forces full total calculation whenever the cart contains a subscription product) + * Fix: incorrect next payment date when changing payment method to PayPal or switching subscriptions using PayPal as the payment method + * Fix: display the price suffix on variable subscriptions when a custom suffix is set under "WooCommerce > Settings > Tax" + * Fix: do not require payment for subscriptions for 1 billing period (e.g. $10 for 1 year) when a product or cart coupon has been applied for 100% of the initial amount + +2014.04.09 - version 1.4.10 + * Tweak: Do not add the "Payment received" order note when a subscription has a free trial and no sign-up fee, instead display "Payment authorized" for subscriptions using automatic recurring payments and "Free trial commenced" for those using manual renewals + * Tweak: On the Manage Subscriptions screen, do not display the subscription sign-up date in the "Last Payment" column if the subscription has a free trial and no sign-up fee (as there was no actual payment made) + * Tweak: Reinstate enctype='multipart/form-data' on the subscription add to cart form to fix compatibilty with file uploads via Product Addons extension (related to http://docs.woothemes.com/document/subscriptions/faq/#section-45) + * Tweak: Only output P tags for product meta on My Subscriptions table when product has meta + * Tweak: add enable/disable field for the Customer Renewal Invoice email + * Fix: set correct order date and ID in renewal order emails' subject & heading when sending more than one email in the same request (workaround until woothemes/woocommerce#5168 is implemented in WC 2.2) + * Fix: correctly close P tag when display product meta data on the My Subscriptions table + * Fix: display correct tax and shipping total on cart page when using Local Pickup shipping method and estimating shipping with the "Calculate Shipping" method + * Fix: display correct tax string when the "Display Tax Totals" setting is set to "As a single total" - requires new 'woocommerce_cart_totals_taxes_total_html' filter introduced with WC 2.1. See woothemes/woocommerce#5184 for details. + * Fix: capitalise custom attribute names in the product meta section of the My Subscriptions table + * Fix: allow standard products variations to be deleted (but still prevent subscription variations from being permanently deleted) + * Fix: Maximum execution time error when attempting to switch from a paid subscription to a free subscription + * Fix: Include billing period on a variable subscription's price when one variation is free (e.g. "From: $0.00 / month" instead of "$0.00 - $10.00") + * Fix: fix potential warnings on Manage Subscriptions admin screen when ordering by next payment date (caused by get_all_users_subscriptions() treating fee line items as product line items) + * Fix: don't duplicate renewal orders for subscriptions with a sign-up fee, but $0 recurring total and using a manual renewal method (or when automatic payments are disabled) + * Fix: move .pot file into /languages/ directory + +2014.03.12 - version 1.4.9 + * Tweak: display each payment gateway's automatic payment support on the checkout settings tab of WooCommerce 2.1 + * Tweak: when removing a subscription variation, trash it instead of permanently deleting it to prevent issues with renewal + * Tweak: prevent subscription products from being permanently deleted, either by store manager or WordPress auto-purge. Deleting subscription products causes assorted issues with WooCommerce core and other plugins/themes as they often expect the product to still exist. All subscription products can still be trashed, but only subscription products not associated with an order (i.e. that have not been purchased) can be deleted. + * Tweak: when attempting to manually renew a subscription for a product which has been deleted, throw a notice if the corresponding product has been deleted + * Tweak: do not display the "Renew" button on the My Subscriptions table if the corresponding subscription product has been permanently deleted. + * Tweak: warn store manager when deleting a user that it will remove them from any subscriptions + * Tweak: include subscription price string for any additional fees on an order to make it clear to the customer they are added for each billing period + * Tweak: improve recurring discount string for WC 2.1 (e.g. show "-$100 / week" not "-$100 now then $100 / week") + * Tweak: more concise subscription price string for taxes, discounts and fees by removing trial period unless the first payment is free + * Fix: Searching subscriptions by user details on the Manage Subscriptions table + * Fix: making a payment for a failed renewal order in WC 2.1 + * Fix: assorted renewal via checkout notices in WC 2.1 + * Fix: update change payment method template to ensure submit button has correct text with WC 2.1 + * Fix: don't link a subscription's title to the product's page on the "My Subscriptions" table if the product has been deleted + * Fix: don't display price range for Variable Subscription's "From" price in WC 2.1 (because it doesn't make sense for prices on different billing periods) + * Fix: do not duplicate order fees + * Fix: do not apply order item fees to free trial period + * Fix: workaround assorted WPML bugs with variable subscription products, like a "-1 variation trial length", when using the "Edit Product" screen as the Product Translation Interface + * Fix: use WC 2.1's order currency API to display currency at time of placing order rather than currency currently used on the store + * Fix: when using PayPal's PDT and IPN, do not create a duplicate order on subscription sign-up + +2014.02.13 - version 1.4.8 + * Tweak: remove support for WooCommerce versions prior to 2.0 (i.e. 1.x) + * Tweak: add new 'woocommerce_subscriptions_renewal_order_item_name' filter + * Tweak: don't display recurring payment method on My Subscriptions table when subscription uses manual renewals + * Tweak: improve subscription price field styles on Edit Product page for WC 2.1 + * Fix: ordering subscriptions by start, expiration or end date on the Manage Subscriptions screen + * Fix: changing payment method in WooCommerce versions before 2.1 + * Fix: add new recurring shipping items correctly on the Edit Order screen in WC 2.1 instead of adding it as a 'shipping' row + * Fix: update order item shipping meta (i.e. method & cost) on the Edit Order screen in WC 2.1 instead of adding new meta rows + * Fix: set renewal order taxes to those calculated at the time of checkout when renewing a subscription via checkout, either due to manual payments or payment in lieu of failed automatic renewal payment + * Fix: set fees on renewal orders if a fee is added to a renewal order when renewing a subscription via checkout, either due to manual payments or payment in lieu of failed automatic renewal payment + * Fix: set correct renewal order shipping line items in WC 2.1 + * Fix: display correct recurring shipping total in WC 2.1 on the Edit Order screen for orders created pre-WC 2.1 + * Fix: correctly update recurring shipping items in WC 2.1 when saving the Edit Order screen for orders created pre-WC 2.1 + * Fix: prefill recurring payment method (as manual) when manually adding a subscription via the Edit Order screen + * Fix: display account creation fields in WC 2.1 when cart contains a subscription and guest checkout is enabled + * Fix: display correct recurring shipping amount on orders with WC 2.1 when there is a free trial + * Fix: subscription length sent to PayPal with WC 2.1 + +2014.01.29 - version 1.4.7 + * Fix: Fatal error "Call to undefined function woocommerce_round_tax_total()" when saving an order on the Edit Order screen with no value set for recurring tax and recurring shipping tax + +2014.01.21 - version 1.4.6 + * Tweak: Full WooCommerce 2.1 compatibility + * Tweak: If a subscriber visits a variable subscription's page, and that subscription is limited to one per customer, and the customer has an active subscription, automatically initiate the switch process. + * Tweak: Display subscription's address not default address when changing a single subscription's address (requires on WooCommerce 2.1) + * Tweak: Add new 'woocommerce_subscriptions_pre_update_recurring_payment_method' hook + * Fix: When customer is changing payment method, cancel subscription with PayPal only after the customer has completed the sign-up (not when initiating the payment method change) + * Fix: Display sale price field on the Edit Product screen for both variable products & variable subscription products when Stock Management is disabled (was previously hidden erroneously) + * Fix: Variable subscription "From:" price when a variation has the same initial price, made up of of a free trial and sign-up fee, but a different recurring price on the same billing period. + * Fix: Colspan for variation's subscription pricing fields on the Edit Product page when Stock Management is enabled. + * Fix: "Notice: Trying to get property of non-object" when searching by user name for a user which doesn't exist + * Fix: don't expire a subscription when a PayPal EOT notification comes through for a subscription that has already been cancelled + * Fix: don't expire a subscription when the recurring payment method has been changed from one PayPal account to another PayPal account + * Fix: make sure when changing the payment method on a subscription that all payment gateways have been initialised before attempting to cancel the subscription with the current payment gateway (to make sure the subscription is correctly cancelled) + * Fix: don't include Subscription Information in new order email unless that admin option is checked (it is off by default) + * Fix: make sure recurring discounts applied before tax are correctly applied to manual renewals and manul renewal of a failed automatic payment + * Fix: Add PayPal IPN transaction ID check for those transactions which have an 'txn_id' field to further try and prevent duplicate IPN handling + * Fix: Ignore PayPal's 'subscr_eot' IPN message completely. It is incorrectly documented and leads to assorted bugs when appearing at unexpected times, like immediately for subscriptions of only one payment, at the same time as the last payment and when a subscription is cancelled due to failed payments. Subscriptions internal scheduleding is now used exclusively for expiring a subscription and it's failed payment handling is used whenever a subscription payment fails. + +2013.12.13 - version 1.4.5 + * Tweak: Add bulk suspension, cancellation and reactivation options to the Manage Subscriptions screen - just in time for the holidays. + * Tweak: Apply the 'woocommerce_order_again_cart_item_data' filter when renewing a cancelled/expired subscription to carry over order item meta from extensions like Gravity Forms Addons + * Tweak: New option to display subscription start & end date in new order email (on by default), customisable via the /emails/subscription-info.php template. + * Tweak: Log subscription activation after setting subscription schedules to help diagnose issues with scheduling next payment, expiration date & trial end date. + * Tweak: First pass at WooCommerce 2.1 compatibility. + * Tweak: Display "When Cancelled" on the My Subscriptions table's "End Date" column for active subscriptions with no expiration. + * Tweak: Display staging/live mode in the system status report. + * Tweak: Use a subscription length string that is easier to translate - i.e. "1 year" instead of "%s %s" + * Tweak: When getting the next payment date for a subscription, reschedule the next payment if it has been dropped. Allows for automatic repair of dropped next payment dates by visiting the Manage Subscriptions screen. + * Fix: When manually adding a variable subscription product to the cart, set the line subtotal/total to be the variation's total. + * Fix: only apply subscription coupons to subscription products even if custom code is allowing products + subscriptions in the cart. + * Fix: correctly add subscription variation meta data to order, not variable subscription meta data, when manually adding a subscription. + * Fix: make sure fees are never added more than once. Fixes integration with Payment Gateway based Fees extension. + * Fix: don't block the order's initial tax row when adding a recurring tax row on the Edit Order screen. + * Fix: in the My Subscriptions table, display the order item's meta data, not the meta data on the order item's products. Means that when order item meta data is changed, the correct meta is shown in the My Subscriptions table. + +2013.11.26 - version 1.4.4 + * Tweak: reinstate Subscription Search on the Manage Subscriptions screen for stores with less than a few thousand subscriptions + * Tweak: don't send subscription related emails on staging sites + * Tweak: only load WooCommerce & Subscriptions admin CSS on WooCommerce & Subscriptions administration screens + * Tweak: massive performance improvement for get_all_users_subscriptions() function (used on Customer Report page). Props Ken Bass (www.kenbass.com) & Chuck Mac (www.chuckmac.info). + * Tweak: the "Suspend" button is no longer displayed on a store manager's My Account page when 0 suspensions are allowed, to avoid confusion (store managers can always suspend subscriptions from the Manage Subscriptions screen) + * Tweak: include whether WCS_DEBUG mode is enabled in the WooCommerce System Status report. + * Tweak: use a string literal text domain for translation plugins which don't support variable text domains, like the latest version of WPML + * Tweak: try to align next payment dates with PayPal's schedule by update the next payment date time whenever PayPal processes a payment + * Tweak: reduce hidden meta data included on the Manage Subscriptions screen to improve load time and provide cleaner search/filter URLs + * Tweak: new 'woocommerce_subscription_lengths' filter for custom subscription lengths: http://docs.woothemes.com/document/subscriptions/faq/#section-57 + * Tweak: add related order links to the Edit Order screen for renewal orders of cancelled/expired subscriptions + * Fix: only load email classes once in case something is destroying and then reinitiating the Woocommerce::woocommerce_mailer property + * Fix: only set recurring payment method meta data when an order is marked as completed for those orders that contain a subscription + * Fix: switching subscriptions without apportioning sign-up fees + * Fix: allow switching between variations of a variable subscription that is limited to one per customer + * Fix: fix next payment date displayed for Subscriptions purchased with PayPal that include a free trial and have their first payment processed after the free trial had ended + * Fix: prevent 'Request-URI Too Large' errors on the Manage Subscriptions page for some servers when using the customer, product and/or page filters + * Fix: set subscriptions manually added via the Edit Order screen to use the Manual Renewal process + * Fix: display correct next payment date for the first payment on a switched subscription where the store manger has changed the next payment date + +2013.10.22 - version 1.4.3 + * Tweak: add a work around to support selling subscriptions with a $0 recurring total via PayPal + * Tweak: more specific PayPal notice about changing Receiver Email on the PayPal settings page + * Tweak: set a subscription's start date based on the date payment was completed rather than date the order was originally placed + * Tweak: update subscription add to cart template to use a hidden field for the product ID, matching changes in WooCommerce 2.0.15 to work with plugins like Quick View + * Tweak: speed up Manage Subscriptions page load slightly when viewing all subscriptions with no customer/product filters + * Tweak: memory usage improvement by no longer autoloading cron locks + * Tweak: when getting the next payment date, don't fall back to calculate it when the completed payment count >= subscription length (speeds up My Account page & Manage Subscriptions screen) + * Fix: use new 'woocommerce_order_needs_payment' in WC > 2.0.16 to require a payment method be set on orders with a free trial (and therefore, $0 order total) + * Fix: set correct recurring payment method when paying for a previously placed order using + * Fix: display correct recurring payment gateway name in warning dialog on the Edit Order screen when the recurring payment method is different to the original order's payment method + * Fix: when a user has no subscriptions, link to the store on user's account page instead of linking to the user's account page. + * Fix: don't include trashed subscriptions in subscription count and pagination when viewing "all" subscriptions on the Manage Subscriptions page + * Fix: call correct 'woocommerce_subscription_completed_payment_count' filter (was using 'woocommerce_subscription_failed_payment_count' hook) + * Fix: set correct first payment date when downgrading a subscription + +2013.10.01 - version 1.4.2 + * Tweak: improve performance for My Account page. + * Tweak: improve the Customer Renewal Invoice email template used when an automatic renewal order failed. Now includes information about the failed payment and a link to My Account page to complete payment. + * Tweak: handle PayPal IPN requests for subscriptions created with different systems by requiring only the 'subscr_id' value, not the 'invoice' & 'custom' PayPal IPN values (which would be different or not exist for subscriptions created with another system). + * Fix: variable subscription "From:" price when a variable has the same initial price but a longer billing period. + * Fix: update staging site lock to prevent duplicate payments for staging sites created with WP Engine. + * Fix: send Customer Renewal Invoice email when automatic renewal payment failed. + * Fix: "The requested URL's length exceeds the capacity limit for this server" error that occurs on some servers when filtering or modifying orders on the Orders administration screen. + * Fix: always show store managers a warning when trashing an order for a subscription via the Orders administration screen. + +2013.09.25 - version 1.4.1 + * Fix: renewal of cancelled subscriptions from the "My Account" page + * Fix: display subscriptions created as a renewal in the customer's "My Subscriptions" table + * Fix: don't display "Change Payment Method" button on the "My Subscriptions" table unless at least one payment gateway on the site supports the payment method being changed. + * Fix: don't display "Change Payment Method" button on the "Checkout -> Pay" form unless at least one payment gateway on the site supports the payment method being changed. + * Fix: error message displayed when no payment gateways are available to change the payment method. + +2013.09.20 - version 1.4 + * New: "Switch" subscriptions feature to allow customers to upgrade/downgrade their subscription: http://docs.woothemes.com/document/subscriptions/switching-guide/ + * New: The payment method used for automatic recurring payments can now be changed by subscribers: http://docs.woothemes.com/document/subscriptions/customers-view/#section-5 + * New: Database structure for subscriptions to improve performance on all sites and fix memory exhaustion issues on sites with tens of thousands of subscriptions. Subscription meta data is now stored in order meta not user meta. + * New: The recurring shipping method, title, tax and total used for renewals can now be changed by store managers from the 'Edit Order' page of the original order used to purchase the subscription (for those payment gateways which support changing recurring amounts). + * New: Renewal order emails: using the WooCommerce email system for renewal orders so they can be enabled/disabled, and the content/templates can be customised. + * New: "Cancelled Subscription" email sent to store managers. + * New: 'get_subscriptions()' function to help developers easily access and filter customer's subscriptions. + * New: 'woocommerce_subscriptions_update_users_role' filter to allow extensions to prevent Subscriptions from handling user roles. + * New: Subscription status, status/end/expiration dates, completed payment list and other meta data can now be changed via the order item meta UI on the Edit Order screen when the WCS_DEBUG constant is set to true. + * Tweak: Order totals are now $0 when signing up for a subscription with a free trial period making revenue reporting more accurate. Totals on all existing orders will be changed when upgrading. + * Tweak: On the customer "My Subscriptions" table, show "End Date" column, instead of "Expiration". This column now includes the end date for cancelled/switched subscriptions, or the end of prepaid term date if a cancelled subscription is still within a prepaid term. + * Tweak: The payment method used for recurring payments is now displayed on the customer's My Subscriptions table (if there are future payments scheduled) + * Tweak: Allow template overrides of the /single-product/add-to-cart/subscription.php template + * Tweak: If the WooCommerce "Hold Stock" setting is at the default 60 minutes, Subscriptions will change it to 1 week on upgrade. This avoids the possibility of subscriptions being cancelled when a manual renewal payment is due or when payment takes longer than 60 minutes, like it does when using a PayPal eCheck. + * Tweak: If a subscription is cancelled without payment, don't allow for it to be renewed (to avoid circumventing sign-up fees) + * Tweak: The Subscriptions shortcode now includes variation data + * Tweak: Use WooCommerce date format instead of default WordPress date format + * Tweak: Reduce memory usage by no longer loading every payment gateway on every page request just to access PayPal's settings + * Tweak: A subscription is now cancelled with PayPal after a failed payment, rather than allowing PayPal to retry the failed payment n times every 5 days. This allows for Subscriptions to handle the failure, meaning it can immediately suspend the subscription and the customer can log in to pay for the renewal. + * Fix: Display correct default settings + * Fix: If a subscription is cancelled before being paid for, don't show the "Pay" button next to it on the My Subscriptions table + * Fix: When using a sign-up fee equal to the recurring fee, and a free trial, make sure the correct amount is sent to PayPal + * Fix: For variable subscription products with no variations, fix the price string (should be "-" not " / day"). + * Fix: Don't assign the deafult inactive role to a subscriber when one subscription is cancelled/expires, but they have other active subscriptions + * Fix: Incorrectly charging a second payment for subscription variations that were meant to be for 1 billing period + * Fix: Incorrectly set trial expiration dates for subscription variations which have different trial expirations + * Fix: Line totals when adding a subscription product with a free trial and/or sign-up fee via the Edit Order screen + + +2013.08.15 - version 1.3.11 + * When a subscription payment is due on the 29th or 30th day of February (i.e. it is normally charged on the 29th or 30th day of the month, and the next billing month is February) charge on the last day of February instead. More details: http://docs.woothemes.com/document/subscriptions/faq/#section-16 + * Trial and subscription expiration dates now match payment dates by using the last day of the month instead of PHP's strtotime() month addition + * Fix Variable Subscription prices when not all variations have a sale price or sign-up fee + * Fix duplicate payment bug where a 2nd payment was charged (n - 1) hours after the first for the payment immediately following a free trial when the next payment date was changed for a subscription with a free trial that was manually added to the site on sites using a timezone of UTC -n + +2013.08.08 - version 1.3.10 + * WP 3.6 PHP Strict Notice fixes + * On renewal orders, use the WC_Product->get_title() method to make sure product name goes through the 'woocommerce_product_title' filter + * Only display subscription meta bulk edit options on Variable Subscriptions (not Variable products) + * Fix incorrect suspension bug on IPN payments from PayPal when not using manual renewal payments + * Fix variation_id & product_id for Variable Subscriptions on the renewal order of an expired/cancelled subscription + +2013.08.07 - version 1.3.9 + * Handle bulk edit of sale prices & percentage increase or decrease of a simple subscription's regular price or sale price + * When automatic payments are switched off, and therefore, the subscription will be using manual payments, payment details (e.g. credit card details) are no longer required when purchasing a subscription with $0 due up front + * Fix bug where subscriptions purchased with PayPal and 'automatic payments' switch off were not suspended when renewal was due + * Fix subscription price string when initial discount is applied to a subscription for only 1 billing period (e.g. $10 for 1 month) + * Fix strict notice: "Strict Standards: Declaration of WC_Product_Subscription_Variation::get_price_html() should be compatible with WC_Product::get_price_html($price = '')" + * Fix the transfer of a subscription variation's attributes when it is manually renewed via checkout + * Fix saving a product when switching between simple and variable subscription while keeping variations associated with the subscription + * Fix display of sale prices on subscription variations + +2013.07.25 - version 1.3.8 + * When a user is deleted from WordPress (or a WordPress network) cancel their subscriptions with the payment gateway + * New safeguard against duplicate payments from staging sites + * Only display admin notices to users who have the capability to remedy the notice + * Fix bug when calling user_has_subscription() without a status (this bug was also affecting the change address functionality) + * Fix bug where customer still needed to provide a payment method when applying a recurring coupon equal to 100% of a subscription to a subscription with a free trial period + * Fix coupon bug where combining product discount and recurring discount allowed a total less than 0. + * Fix bug where a payment method was not set when a coupon discounted the initial payment to $0 but there were still recurring amounts to be paid. + * Fix cornercase fatal error where is_woocommerce_active() is not defined because woothemes_queue_update() is being defined without also defining is_woocommerce_active() + +2013.07.12 - version 1.3.7 + * Display "From: " on a variable product's price when it has different billing schedule but same price + * Display sign up fee and subscription length on variable products "From:" price + * Account for free trials and sign up fees when calculating the cheapest possible variation in a variable subscription + * Fix PayPal bug with coupons for first payment only + +2013.07.05 - version 1.3.6 + * New Recurring % Discount and Sign-up Fee % Discount coupons + * Set subscription coupon discount amounts in cart meta data + * New HTML5 elements for price fields on Edit Product page (adds validation so a string like $5 will no longer validate as a price) + * Improve validation of pricing fields for subscription products + * Improve layout of variable subscription pricing fields on small screens + * Fix bug where price was not displayed for a subscription variation when all prices were equal, but billing periods were not. + * Fix carry over of deprecated 'role' param on WC_Subscriptions_Renewal_Order::is_renewal() + * Fix tax total displayed on orders when recurring tax total is the same as initial tax total (e.g. display "$2 / month" not "$2 up front then $2 / month") + * Fix bug where shipping was not added to first payment for subscriptions with a sign-up fee and no free trial + * Fix bug where recurring coupons were not discounting the first payment for subscriptions with a sign-up fee and no free trial + * Fix bug applying discount coupons twice to manual renewals and renewal of a failed automatic payment + +2013.06.24 - version 1.3.5 + * Coupon Behaviour Change: due to popular demand, WooCommerce's Product and Cart coupons now discount only the first payment. This also improves compatibility with the Point & Rewards and Gift Certificate extensions. More details: http://docs.woothemes.com/document/faq/#section-3 + * Add new 'WCS_DEBUG' mode to make it easier to test renewal payments. More details: http://docs.woothemes.com/document/subscriptions/faq/#section-29 + * Add new option to limit a subscription product to one active subscription per customer + * Update translation files (pot file) + * Hide redundant "Sold Individually" checkbox on "Edit Product" screen (subscriptions can only be purchased individually) + * Display required "*" next to account fields when a subscription is in the cart + * New $status parameter for WC_Subscriptions_Manager::user_has_subscription() + * Fix incorrect payment date times when another plugin is behaving badly and calling date_default_timezone_set() to set timezone to something other than WordPress default - UTC + * Fix activation issue (500 error) for first time activation on sites with a large number of existing users + * Fix bug in variable subscription price string for those variable subscription products which were once simple subscriptions + * Fix tax price string displayed for WC 2.0.6+ + * Fix tax inclusive shipping prices displayed on an order after it has been placed + * Fix bulk trash action on the Manage Subscriptions page + +2013.06.06 - version 1.3.4 + * Fix subscription string when not charging a recurring payment but including a sign-up fee (e.g. display $0 / month with a $20 sign-up fee instead of "Free!") + * Fix the "From:" price displayed on a Grouped product which contains a subscription which has a $0 recurring amount but a sign-up fee + * Fix hardcoded DB table name in upgrade process to version 1.3. + +2013.05.13 - version 1.3.3 + * Use a translation safe admin page screen ID to fix issues with WooCommerce Branding extension + * Fix automatic renewal order emails sent for orders transitioning straight to completed even when email notification off + * Fix PHP warning "'WC_Product_Variable_Subscription' does not have a method 'get_sign_up_fee'" when renewing a Variable Subscription product + * Fix incorrect subscription billing interval for subscriptions started on the last day of the month for an interval greater than one + * Fix PayPal IPN transaction ID bug preventing some sign-ups from being handled correctly + * Fix a variable subscription's "From:" price billing interval when all variations have an interval different to 1 + +2013.04.19 - version 1.3.2 + * Change active subscription cancellation behaviour - now when cancelling a subscription, the subscriber's role is changed at the end of the prepaid term (instead of as soon as they cancel their subscription) + * Add safeguard against duplicate PayPal IPN requests triggering duplciate renewal orders & renewal order emails + * Fix Manage Subscriptions page filter for variable subscription products + * Fix sign-up fee currency symbol - was not being shown, now it is + * Fix renewal payment handling for free subscriptions (i.e. those with a renewal payment amount of $0.00) + * Fix PayPal configuration notice when PayPal is unusable (because the store's currency isn't supported) + * Fix bug in paying for non-renewal orders via the "Pay" link sent via email + +2013.03.26 - version 1.3.1 + * Add screen options pane to Manage Subscriptions admin page to show/hide columns & set number of subscriptions to display + * Add product filter to Manage Subscriptions admin page + * Add customer filter to Manage Subscriptions admin page + * Fix incorrect cancellation of subscriptions purchased with PayPal for one billing period that has an interval other than 1 (e.g. $5 for 2 months) + * Fix PayPal IPN handling with WooCommerce 2.0.4, which no longer strips slashes in $_POST + * Make the text on the add to cart & place order buttons available for translation on multilingual sites using WPML + +2013.03.15 - version 1.3 + * Requires WooCommerce 2.0 or newer + * New Variable Subscription product type, woo! + * New 'subscription_end_of_prepaid_term' hook triggered at the end of the paid up period for subscriptions that have been cancelled before their end date + * New filters on all subscription product meta data (e.g. 'woocommerce_subscriptions_product_period') + * New 'woocommerce_my_account_my_subscriptions_actions' filter for action buttons displayed on "My Subscriptions" table + * New option to turn off automatic payments for all new orders + * Add new customer suspension limit setting also allowing store managers to turn off customer suspensions completely (site admins can always suspend any subscription) + * Add checkbox to allow subscribers to change the address used on all active subscriptions + * Add "Change Shipping Address" action to "My Subscriptions" table to allow subscribers to change the shipping address on a specific subscription + * Improve failed renewal order handling - rather than changing the failed renewal orders status, keep the order as a record of the failed payment & create a new renewal order for the payment + * Improve "My Subscriptions" table action links - right aligned buttons to match WooCommerce order actions. Update to custom my-subscriptions.php templates required. + * Improve free shipping string on orders + * Improve cart shipping string + * Fix order totals displayed on orders after payment + * Fix for potential duplicate renewal payments when W3 Total Cache or another caching plugin is enabled + * Fix manual addition of non-subscription products to an order + * Fix bug adding outstanding balance even when admin has requested not to add outstanding balances + * Fix bug which would allow a customer to reactivate a subscription that required payment when using an out-of-date my-subscriptions.php template + * Fix order totals when prices inclusive of tax in WC 2.0 + * No longer allow downloads for files associated with a subscription that has expired, been cancelled or is on-hold + * Old upgrade routine for versions 1.1.x -> 1.2 can now be run when prematurely upgrading WooCommerce to version 2.0 + * Avoid potential conflict with other subscription plugins (if any exist) when checking if an order item is a subscription + * Work with corrected processing order email hook in WC 2.0 to fix processing subscription renewal orders in WC 2.0+ + * Simplified cron lock system - using single option instead of transients + * Customer suspensions now off by default + +2013.02.13 - version 1.2.5 + * WC 2.0 Compatibility + * Fix race condition for user registration/guest checkout (in WC2.0+ only) + * Fix warning logged when processing a payment with PayPal: "Invalid profile status for reactivate action; profile should be suspended" + * Fix next payment date when date is last day of month (to workaround PHP strtotime() quirk) + +2013.01.16 - version 1.2.4 + * Add feature to manually change subscription details on an existing subscription (for gateways that support it) + * Add pre-fill subscription details feature when manually adding a subscription via the "Add Order" administration screen + * Add new shortcode for displaying a user's subscriptions - either use [subscriptions] to display subscriptions purchased by the currently logged in user or [subscriptions user_id=""] to display for a specific user + * Add new 'gateway_scheduled_payments' supports flag for payment gateways to handle schedules themselves (with bulk processing, like PayPal) + * Add new 'woocommerce_subscriptions_max_failed_payments_exceeded' hook for gateways & plugins to override defaults + * Add new 'woocommerce_subscriptions_list_table_pre_process_actions' for adding custom actions to Manage Subscriptions admin table + * Add "Trial Expiration Date" to the Manage Subscriptions table + * Add filter to thank you message + * Improve PayPal IPN handling by making it independent of the invoice ID stored at PayPal for the subscription (to help with migrations & imported subscriptions) + * Improve names of available payment gateways + * Improve efficiency of multiple loops by fixing logic + * Fix new WP3.5 warnings + * Fix sorting of subscriptions by Next Payment Date in the Manage Subscriptions table + * Fix "Suspend" link incorrectly being shown for pending subscriptions on a customer's "My Subscriptions" table + * Fix incorrect suspension of subscriptions upon renewal for gateways that process payments in batches like PayPal (and therefore, may process a payment before it is scheduled) + * Fix manually adding subscriptions via the add order screen + * Fix "Fatal error: Call to a member function supports() on a non-object" + * Fix order totals for products with free trial and sign-up fee + * Fix minor JavaScript error attempting to validate trial length on pages other than "Edit Product" + * Fix PayPal suspend & reactivate profile status change notes (previously, all notes said "cancelled") + * Fix handling of PayPal failed payment IPN requests + * Fix buttons on activation message for WP3.5+ + * Fix bug in subscription search + * Fix suspension of a subscription when a payment fails + * Fix get_order_subscription_string() when subscription is for 1 billing period (and generally improve it by using get_formatted_order_total()) + +2012.12.04 - version 1.2.3 + * New updater + * Fix checkout bug when selling free subscriptions (or subscriptions with a 100% discount coupon applied) + * Make subscription status translatable + * Don't show add to cart template when subscription product is not purchasable + +2012.11.27 - version 1.2.2 + * Fix backward compatibility for subscriptions with a free trial period that existed prior to customisable trial periods added in version 1.2 + * Fix bug in cart & order subscription price strings being displayed when there is a free trial period & no sign-up fee + * Fix formatted line subtotal for subscriptions in admin new order emails + * Fix bug in cart subtotal when displayed in cart widget + * Fix subscription activation & order status change for payment with PayPal when a subscription includes a free trial and no sign-up fee + * Delete renewal orders generated by PayPal which are a duplicate of the initial order + * Improved tax line items on review order page when a subscription is in the cart + * Simplified subscription string for subscriptions with no sign-up fee or trial period + +2012.11.16 - version 1.2.1 + * Fix suspension bug where next payment date after calculated after a subscription has been suspended was not always in the future + * Fix bug with PayPal where a renewal order was created for the first subscription payment + * Fix bug in version 1.2 upgrade routine causing renewal orders to duplicate if run more than once (for example, after a timeout) + * Fix coupon bug preventing non-subscription coupons from being applied + * Changing date of renewal order's generated in 1.2 upgrade routine to be in site time not server time + * Deleting any renewal orders generated in 1.2 upgrade routine which are a duplicate of the initial order + +2012.11.08 - version 1.2 + * Support for sale prices on subscription products + * On the Manage Subscriptions page, store managers can now search for subscriptions by subscriber username, email, order ID, product ID or subscription item name (subscription product name at time of purchase) + * Any payment gateway with a WooCommerce extension can now be used to purchase and renew subscriptions via manual payments + * Subscribers can now use a different payment method for each recurring payment via manual payments + * Subscribers can now change the payment method on a subscription if an automatic payments fails + * A subscription's next payment date can now be changed if the payment gateways used to purchased it can change the date + * WooCommerce reports now include revenue from subscription's recurring payments + * Improved record keeping for recurring subscription payments with a renewal order created for each payment + * Improved shipping management for subscriptions with physical goods through new renewal order system - each billing period, a processing order is created and store manager can mark the order as complete once the product for that period has been shipped + * Improved subscription event logging on orders - subscription product name is now used instead of subscription key + * Suspended subscription status changed to "on-hold" to match the WooCommerce Order status + * When an order for a subscription is placed "on-hold" the subscription in that order is also placed "on-hold" + * When subscription sign-up or renewal payment fails, subscriptions are now placed "on-hold" instead of "failed" so the subscription can be activated when payment is completed on the order + * A customer can now renew a subscription that was cancelled or expired via a link on the "My Subscriptions" page + * The subscription name displayed in the "Manage Subscriptions" and "My Subscriptions" tables now comes from the name at the time of the order not the current post item + * Fixing bug in subscription reactivation - if a subscription expiry is before the reactivation date, the subscription now correctly expires soon after being activated instead of remaining active + * Subscriptions in the trash can now be permanently deleted + * Fixed the "Active Subscriber?" user column value to display even when other plugins are #doingitwrong + * Next payment, last payment, start date and expiration dates are now displayed in blog/site time instead of server time + * For dates within 24 hours, a more human friendly hourly time difference is now displayed instead of just the date + * Removed end date and start date from My Subscriptions template so that it now shows only next payment and expiration dates + * Improve subscription price description when subscription is only for one period, e.g. "$5 for 3 months" is now used instead of "$5 every 3 months for 3 months" + * Fix bug in PayPal Standard integration affecting subscriptions for one billing period and a interval greater than 1 (e.g. a subscription of $5 for 3 months) + * Allow for different trial period to billing period (if the gateway supports it), e.g. a 2 week free trial with $5 per month subscription is now possible + * Add a coupon to apply fixed discount to a subscription's sign up fees only + * Add a coupon to apply fixed discount to a subscription's recurring payment only + * Add renewal order count column to 'Manage Subscriptions' table + * List renewal orders on the "Edit Order" page for the initial order of a subscription + * Fix bug in subscription price string for subscriptions with a trial period and a sign-up fee = recurring price + shipping + +2012.09.27 - version 1.1.8 + * Add safeguard against firing the scheduled payment hook if the subscription is suspended, on-hold, cancelled or expired + +2012.09.19 - version 1.1.7 + * PayPal Standard expires a subscription immediately if it is only for one billing period, this doesn't make sense, so Subscriptions now ignores PayPal IPN request to expire a subscription immediately and instead expires the subscription after one billing period has passed + +2012.09.07 - version 1.1.6 + * Updating renewal order email to work with changes in WooCommerce + * Accounting for failed payments when determining next payment date + * Adding a workaround for ISS issues with PHP's strtotime() function so expiry, trial & next payment dates are consistent across all servers + * Fixing renewal order ID added in the order note on the original order + * Fixing bug in automatic subscription and order cancellation when the maximum number of allowable failed payments is reached + * Fix bug in WC_Subscriptions_Order::get_failed_payment_count() + +2012.09.03 - version 1.1.5 + * Improving safeguard against WP-Cron infinite loop by adding fallback value for payment blocking transient + * Fixing bug in payment safeguard relating to large subscription keys (order ID & product ID) + * Better handling of subscription with no-future payments + +2012.08.27 - version 1.1.4 + * Adding a workaround to deal with PayPal invoice prefix changes and IPN requests for orders using the old invoice prefix + * More conventional text-domain for internationalisation - woocommerce-subscriptions instead of woocommerce_subscription + * Upgrade safe internationalisation by allowing language files to be located in /wp-content/languages/woocommerce-subscriptions/ + * Fixing PayPal issue when no invoice prefix is set + * Fixing status message issues on Subscription's Management Page + * Fixing "Creating default object from empty value" warning in PHP version newer than 5.4 + +2012.08.22 - version 1.1.3 + * Updating to WooCommerce 1.6.3 PayPal invoice/order key changes + * Better sorting of dates, especially last/next payment dates in the Subscriptions Management table + * Ordering users by display name in Subscriptions Management table + * Fixing products set as subscriptions against a users account in 1.1.2 + * Adding version dependant upgrade script + +2012.08.13 - version 1.1.2 + * Adding a safeguard against WP-Cron's infinite loop + +2012.08.03 - version 1.1.1 + * Fixing multisite bug which displayed a user's subscriptions from each site in a network on every other site in the network + * Fixing multisite activation + +2012.07.26 - version 1.1 + * Free trial periods can now be added to the beginning of a subscription + * Subscriptions can now be manually created by adding a subscription to an order or creating a new order with a subscription + * Subscriptions can now be billed at different intervals of each period, e.g. $5 every 2 weeks or $10 every 6 months + * Subscriptions can now be suspended and reactivated (by subscribers and store managers) + * Subscriptions purchased with PayPal Standard can now be cancelled (and suspended/reactivated) + * Guest checkout & sign-up login WooCommerce admin settings are now honoured for all transactions that do not include a subscription + * When a customer ID is changed on an order, any subscriptions on that order are transferred to the new customer + * Fixing PayPal IPN EOT error causing cancelled subscriptions to be marked as expired + * Fixing i18n implementation to allow translations + +2012.06.21 - version 1.0 + * First Release diff --git a/includes/abstracts/abstract-wcs-cache-manager.php b/includes/abstracts/abstract-wcs-cache-manager.php new file mode 100644 index 0000000..96a04ae --- /dev/null +++ b/includes/abstracts/abstract-wcs-cache-manager.php @@ -0,0 +1,54 @@ + 'old_hook_prefix' */ + protected $deprecated_hook_prefixes = array(); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * We need to use the special 'all' hook here because we don't actually know the full hook names + * in advance, just their prefix. We can't simply hook in to 'plugins_loaded' and check the + * $wp_filter global for our hooks either, because sometime, hooks are dynamically hooked based + * on other hooks. Sigh. + * + * @since 2.0 + */ + public function __construct() { + add_filter( 'all', array( &$this, 'check_for_deprecated_hooks' ) ); + } + + /** + * Check if the current hook contains the prefix of any dynamic hook that has been deprecated. + * + * @since 2.0 + */ + public function check_for_deprecated_hooks() { + + $current_filter = current_filter(); + + foreach ( $this->deprecated_hook_prefixes as $new_hook_prefix => $old_hook_prefixes ) { + + if ( is_array( $old_hook_prefixes ) ) { + foreach ( $old_hook_prefixes as $old_hook_prefix ) { + $this->check_for_deprecated_hook( $current_filter, $new_hook_prefix, $old_hook_prefix ); + } + } else { + $this->check_for_deprecated_hook( $current_filter, $new_hook_prefix, $old_hook_prefixes ); + } + } + } + + /** + * Check if a given hook contains the prefix and if it does, attach the @see $this->maybe_handle_deprecated_hook() method + * as a callback to it. + * + * @since 2.0 + */ + protected function check_for_deprecated_hook( $current_hook, $new_hook_prefix, $old_hook_prefix ) { + + if ( false !== strpos( $current_hook, $new_hook_prefix ) ) { + + // Get the dynamic suffix on the hook, usually a payment gateway name, like 'stripe' or 'authorize_net_cim' + $hook_suffix = str_replace( $new_hook_prefix, '', $current_hook ); + $old_hook = $old_hook_prefix . $hook_suffix; + + // register the entire new and old hook + $this->deprecated_hooks[ $current_hook ][] = $old_hook; + + // and attach our handler now that we know the hooks + add_filter( $current_hook, array( &$this, 'maybe_handle_deprecated_hook' ), -1000, 8 ); + } + } +} diff --git a/includes/abstracts/abstract-wcs-hook-deprecator.php b/includes/abstracts/abstract-wcs-hook-deprecator.php new file mode 100644 index 0000000..40df949 --- /dev/null +++ b/includes/abstracts/abstract-wcs-hook-deprecator.php @@ -0,0 +1,129 @@ + 'old_hook' */ + protected $deprecated_hooks = array(); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * @since 2.0 + */ + public function __construct() { + foreach ( $this->deprecated_hooks as $new_hook => $old_hook ) { + add_filter( $new_hook, array( &$this, 'maybe_handle_deprecated_hook' ), -1000, 8 ); + } + } + + /** + * Check if an old hook still has callbacks attached to it, and if so, display a notice and trigger the old hook. + * + * @since 2.0 + */ + public function maybe_handle_deprecated_hook() { + + $new_hook = current_filter(); + $old_hooks = ( isset( $this->deprecated_hooks[ $new_hook ] ) ) ? $this->deprecated_hooks[ $new_hook ] : ''; + + $new_callback_args = func_get_args(); + $return_value = $new_callback_args[0]; + + if ( ! empty( $old_hooks ) ) { + + if ( is_array( $old_hooks ) ) { + foreach ( $old_hooks as $old_hook ) { + $return_value = $this->handle_deprecated_hook( $new_hook, $old_hook, $new_callback_args, $return_value ); + } + } else { + $return_value = $this->handle_deprecated_hook( $new_hook, $old_hooks, $new_callback_args, $return_value ); + } + } + + return $return_value; + } + + /** + * Check if an old hook still has callbacks attached to it, and if so, display a notice and trigger the old hook. + * + * @since 2.0 + */ + protected function handle_deprecated_hook( $new_hook, $old_hook, $new_callback_args, $return_value ) { + + if ( has_filter( $old_hook ) ) { + + $this->display_notice( $old_hook, $new_hook ); + + $return_value = $this->trigger_hook( $old_hook, $new_callback_args ); + } + + return $return_value; + } + + /** + * Display a deprecated notice for old hooks. + * + * @since 2.0 + */ + protected static function display_notice( $old_hook, $new_hook ) { + _deprecated_function( sprintf( 'The "%s" hook uses out of date data structures so', esc_html( $old_hook ) ), '2.0 of WooCommerce Subscriptions', esc_html( $new_hook ) ); + } + + /** + * Trigger the old hook with the original callback parameters + * + * @since 2.0 + */ + abstract protected function trigger_hook( $old_hook, $new_callback_args ); + + /** + * Get the order for a subscription to pass to callbacks. + * + * Because a subscription can exist without an order in Subscriptions 2.0, the order might actually + * fallback to being the subscription rather than the order used to purchase the subscription. + * + * @since 2.0 + */ + protected static function get_order( $subscription ) { + $order = isset( $subscription->order->id ) ? $subscription->order : $subscription; + return $order; + } + + /** + * Get the order ID for a subscription to pass to callbacks. + * + * Because a subscription can exist without an order in Subscriptions 2.0, the order might actually + * fallback to being the subscription rather than the order used to purchase the subscription. + * + * @since 2.0 + */ + protected static function get_order_id( $subscription ) { + return isset( $subscription->order->id ) ? $subscription->order->id : $subscription->id; + } + + /** + * Get the first product ID for a subscription to pass to callbacks. + * + * @since 2.0 + */ + protected static function get_product_id( $subscription ) { + $order_items = $subscription->get_items(); + $product_id = ( empty( $order_items ) ) ? 0 : WC_Subscriptions_Order::get_items_product_id( reset( $order_items ) ); + return $product_id; + } +} diff --git a/includes/abstracts/abstract-wcs-scheduler.php b/includes/abstracts/abstract-wcs-scheduler.php new file mode 100644 index 0000000..d723484 --- /dev/null +++ b/includes/abstracts/abstract-wcs-scheduler.php @@ -0,0 +1,58 @@ +date_types_to_schedule = apply_filters( 'woocommerce_subscriptions_date_types_to_schedule', array_keys( wcs_get_subscription_date_types() ) ); + } + + /** + * When a subscription's date is updated, maybe schedule an event + * + * @param object $subscription An instance of a WC_Subscription object + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'end', 'end_of_prepaid_term' or a custom date type + * @param string $datetime A MySQL formated date/time string in the GMT/UTC timezone. + */ + abstract public function update_date( $subscription, $date_type, $datetime ); + + /** + * When a subscription's date is deleted, clear it from the scheduler + * + * @param object $subscription An instance of a WC_Subscription object + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'end', 'end_of_prepaid_term' or a custom date type + */ + abstract public function delete_date( $subscription, $date_type ); + + /** + * When a subscription's status is updated, maybe schedule an event + * + * @param object $subscription An instance of a WC_Subscription object + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'end', 'end_of_prepaid_term' or a custom date type + * @param string $datetime A MySQL formated date/time string in the GMT/UTC timezone. + */ + abstract public function update_status( $subscription, $new_status, $old_status ); +} diff --git a/includes/admin/class-wc-subscriptions-admin.php b/includes/admin/class-wc-subscriptions-admin.php new file mode 100644 index 0000000..d2c0c93 --- /dev/null +++ b/includes/admin/class-wc-subscriptions-admin.php @@ -0,0 +1,1567 @@ +ID, '_subscription_period', true ) ) { + $subscription_period = 'month'; + } + + echo '
    '; + + // Subscription Price + woocommerce_wp_text_input( array( + 'id' => '_subscription_price', + 'class' => 'wc_input_subscription_price', + // translators: placeholder is a currency symbol / code + 'label' => sprintf( __( 'Subscription Price (%s)', 'woocommerce-subscriptions' ), get_woocommerce_currency_symbol() ), + 'placeholder' => _x( 'e.g. 9.90', 'example price', 'woocommerce-subscriptions' ), + 'type' => 'text', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), + ) ); + + // Subscription Period Interval + woocommerce_wp_select( array( + 'id' => '_subscription_period_interval', + 'class' => 'wc_input_subscription_period_interval', + 'label' => __( 'Subscription Periods', 'woocommerce-subscriptions' ), + 'options' => wcs_get_subscription_period_interval_strings(), + ) + ); + + // Billing Period + woocommerce_wp_select( array( + 'id' => '_subscription_period', + 'class' => 'wc_input_subscription_period', + 'label' => __( 'Billing Period', 'woocommerce-subscriptions' ), + 'value' => $subscription_period, + 'description' => _x( 'for', 'for in "Every month _for_ 12 months"', 'woocommerce-subscriptions' ), + 'options' => wcs_get_subscription_period_strings(), + ) + ); + + // Subscription Length + woocommerce_wp_select( array( + 'id' => '_subscription_length', + 'class' => 'wc_input_subscription_length', + 'label' => __( 'Subscription Length', 'woocommerce-subscriptions' ), + 'options' => wcs_get_subscription_ranges( $subscription_period ), + ) + ); + + // Sign-up Fee + woocommerce_wp_text_input( array( + 'id' => '_subscription_sign_up_fee', + 'class' => 'wc_input_subscription_intial_price', + // translators: %s is a currency symbol / code + 'label' => sprintf( __( 'Sign-up Fee (%s)', 'woocommerce-subscriptions' ), get_woocommerce_currency_symbol() ), + 'placeholder' => _x( 'e.g. 9.90', 'example price', 'woocommerce-subscriptions' ), + 'description' => __( 'Optionally include an amount to be charged at the outset of the subscription. The sign-up fee will be charged immediately, even if the product has a free trial or the payment dates are synced.', 'woocommerce-subscriptions' ), + 'desc_tip' => true, + 'type' => 'text', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), + ) ); + + // Trial Length + woocommerce_wp_text_input( array( + 'id' => '_subscription_trial_length', + 'class' => 'wc_input_subscription_trial_length', + 'label' => __( 'Free Trial', 'woocommerce-subscriptions' ), + ) ); + + // Trial Period + woocommerce_wp_select( array( + 'id' => '_subscription_trial_period', + 'class' => 'wc_input_subscription_trial_period', + 'label' => __( 'Subscription Trial Period', 'woocommerce-subscriptions' ), + 'options' => wcs_get_available_time_periods(), + // translators: placeholder is trial period validation message if passed an invalid value (e.g. "Trial period can not exceed 4 weeks") + 'description' => sprintf( _x( 'An optional period of time to wait before charging the first recurring payment. Any sign up fee will still be charged at the outset of the subscription. %s', 'Trial period dropdown\'s description in pricing fields', 'woocommerce-subscriptions' ), self::get_trial_period_validation_message() ), + 'desc_tip' => true, + 'value' => WC_Subscriptions_Product::get_trial_period( $post->ID ), // Explicitly set value in to ensure backward compatibility + ) ); + + do_action( 'woocommerce_subscriptions_product_options_pricing' ); + + wp_nonce_field( 'wcs_subscription_meta', '_wcsnonce' ); + + echo '
    '; + echo '
    '; + } + + /** + * Output subscription shipping options on the "Edit Product" admin screen + * + * @since 2.0 + */ + public static function subscription_shipping_fields() { + global $post; + + echo '
    '; + echo '
    '; + + // Only one Subscription per customer + woocommerce_wp_checkbox( array( + 'id' => '_subscription_one_time_shipping', + 'label' => __( 'One Time Shipping', 'woocommerce-subscriptions' ), + 'description' => __( 'Shipping for subscription products is normally charged on the initial order and all renewal orders. Enable this to only charge shipping once on the initial order. Note: for shipping to be charged on the initial order, the subscription must not have a free trial.', 'woocommerce-subscriptions' ), + 'desc_tip' => true, + ) ); + + do_action( 'woocommerce_subscriptions_product_options_shipping' ); + + } + + /** + * Output advanced subscription options on the "Edit Product" admin screen + * + * @since 1.3.5 + */ + public static function subscription_advanced_fields() { + global $post; + + echo '
    '; + echo '
    '; + + // Only one Subscription per customer + woocommerce_wp_select( array( + 'id' => '_subscription_limit', + 'label' => __( 'Limit Subscription', 'woocommerce-subscriptions' ), + // translators: placeholders are opening and closing link tags + 'description' => sprintf( __( 'Only allow a customer to have one subscription to this product. %sLearn more%s.', 'woocommerce-subscriptions' ), '', '' ), + 'options' => array( + 'no' => __( 'Do not limit', 'woocommerce-subscriptions' ), + 'active' => __( 'Limit to one active subscription', 'woocommerce-subscriptions' ), + 'any' => __( 'Limit to one of any status', 'woocommerce-subscriptions' ), + ), + ) ); + + do_action( 'woocommerce_subscriptions_product_options_advanced' ); + + } + + /** + * Output the subscription specific pricing fields on the "Edit Product" admin page. + * + * @since 1.3 + */ + public static function variable_subscription_pricing_fields( $loop, $variation_data, $variation ) { + global $thepostid; + + // Set month as the default billing period + if ( ! $subscription_period = get_post_meta( $variation->ID, '_subscription_period', true ) ) { + $subscription_period = 'month'; + } + + // When called via Ajax + if ( ! function_exists( 'woocommerce_wp_text_input' ) ) { + require_once( WC()->plugin_path() . '/admin/post-types/writepanels/writepanels-init.php' ); + } + + if ( ! isset( $thepostid ) ) { + $thepostid = $variation->post_parent; + } + + include( plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/admin/html-variation-price.php' ); + + wp_nonce_field( 'wcs_subscription_variations', '_wcsnonce_save_variations', false ); + + do_action( 'woocommerce_variable_subscription_pricing', $loop, $variation_data, $variation ); + } + + /** + * Output extra options in the Bulk Edit select box for editing Subscription terms. + * + * @since 1.3 + */ + public static function variable_subscription_bulk_edit_actions() { + global $post; + + if ( WC_Subscriptions_Product::is_subscription( $post->ID ) ) : ?> + + + + + + + + + $now ) ) ) ) { + $price = $sale_price; + } else { + $price = $subscription_price; + } + + update_post_meta( $post_id, '_price', stripslashes( $price ) ); + + // Make sure trial period is within allowable range + $subscription_ranges = wcs_get_subscription_ranges(); + + $max_trial_length = count( $subscription_ranges[ $_POST['_subscription_trial_period'] ] ) - 1; + + $_POST['_subscription_trial_length'] = absint( $_POST['_subscription_trial_length'] ); + + if ( $_POST['_subscription_trial_length'] > $max_trial_length ) { + $_POST['_subscription_trial_length'] = $max_trial_length; + } + + update_post_meta( $post_id, '_subscription_trial_length', $_POST['_subscription_trial_length'] ); + + $_REQUEST['_subscription_sign_up_fee'] = wc_format_decimal( $_REQUEST['_subscription_sign_up_fee'] ); + $_REQUEST['_subscription_one_time_shipping'] = isset( $_REQUEST['_subscription_one_time_shipping'] ) ? 'yes' : 'no'; + + $subscription_fields = array( + '_subscription_sign_up_fee', + '_subscription_period', + '_subscription_period_interval', + '_subscription_length', + '_subscription_trial_period', + '_subscription_limit', + '_subscription_one_time_shipping', + ); + + foreach ( $subscription_fields as $field_name ) { + if ( isset( $_REQUEST[ $field_name ] ) ) { + update_post_meta( $post_id, $field_name, stripslashes( $_REQUEST[ $field_name ] ) ); + } + } + + } + + /** + * Save meta data for variable subscription product type when the "Edit Product" form is submitted. + * + * @param array Array of Product types & their labels, excluding the Subscription product type. + * @return array Array of Product types & their labels, including the Subscription product type. + * @since 2.0 + */ + public static function save_variable_subscription_meta( $post_id ) { + + if ( empty( $_POST['_wcsnonce'] ) || ! wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_subscription_meta' ) || ! isset( $_POST['product-type'] ) || ! in_array( $_POST['product-type'], apply_filters( 'woocommerce_subscription_variable_product_types', array( 'variable-subscription' ) ) ) ) { + return; + } + + if ( isset( $_REQUEST['_subscription_limit'] ) ) { + update_post_meta( $post_id, '_subscription_limit', stripslashes( $_REQUEST['_subscription_limit'] ) ); + } + + update_post_meta( $post_id, '_subscription_one_time_shipping', stripslashes( isset( $_REQUEST['_subscription_one_time_shipping'] ) ? 'yes' : 'no' ) ); + + } + + /** + * Calculate and set a simple subscription's prices when edited via the bulk edit + * + * @param object $product An instance of a WC_Product_* object. + * @return null + * @since 1.3.9 + */ + public static function bulk_edit_save_subscription_meta( $product ) { + + if ( ! $product->is_type( 'subscription' ) ) { + return; + } + + $price_changed = false; + + $old_regular_price = $product->regular_price; + $old_sale_price = $product->sale_price; + + if ( ! empty( $_REQUEST['change_regular_price'] ) ) { + + $change_regular_price = absint( $_REQUEST['change_regular_price'] ); + $regular_price = esc_attr( stripslashes( $_REQUEST['_regular_price'] ) ); + + switch ( $change_regular_price ) { + case 1 : + $new_price = $regular_price; + break; + case 2 : + if ( strstr( $regular_price, '%' ) ) { + $percent = str_replace( '%', '', $regular_price ) / 100; + $new_price = $old_regular_price + ( $old_regular_price * $percent ); + } else { + $new_price = $old_regular_price + $regular_price; + } + break; + case 3 : + if ( strstr( $regular_price, '%' ) ) { + $percent = str_replace( '%', '', $regular_price ) / 100; + $new_price = $old_regular_price - ( $old_regular_price * $percent ); + } else { + $new_price = $old_regular_price - $regular_price; + } + break; + } + + if ( isset( $new_price ) && $new_price != $old_regular_price ) { + $price_changed = true; + update_post_meta( $product->id, '_regular_price', $new_price ); + update_post_meta( $product->id, '_subscription_price', $new_price ); + $product->regular_price = $new_price; + } + } + + if ( ! empty( $_REQUEST['change_sale_price'] ) ) { + + $change_sale_price = absint( $_REQUEST['change_sale_price'] ); + $sale_price = esc_attr( stripslashes( $_REQUEST['_sale_price'] ) ); + + switch ( $change_sale_price ) { + case 1 : + $new_price = $sale_price; + break; + case 2 : + if ( strstr( $sale_price, '%' ) ) { + $percent = str_replace( '%', '', $sale_price ) / 100; + $new_price = $old_sale_price + ( $old_sale_price * $percent ); + } else { + $new_price = $old_sale_price + $sale_price; + } + break; + case 3 : + if ( strstr( $sale_price, '%' ) ) { + $percent = str_replace( '%', '', $sale_price ) / 100; + $new_price = $old_sale_price - ( $old_sale_price * $percent ); + } else { + $new_price = $old_sale_price - $sale_price; + } + break; + case 4 : + if ( strstr( $sale_price, '%' ) ) { + $percent = str_replace( '%', '', $sale_price ) / 100; + $new_price = $product->regular_price - ( $product->regular_price * $percent ); + } else { + $new_price = $product->regular_price - $sale_price; + } + break; + } + + if ( isset( $new_price ) && $new_price != $old_sale_price ) { + $price_changed = true; + update_post_meta( $product->id, '_sale_price', $new_price ); + $product->sale_price = $new_price; + } + } + + if ( $price_changed ) { + update_post_meta( $product->id, '_sale_price_dates_from', '' ); + update_post_meta( $product->id, '_sale_price_dates_to', '' ); + + if ( $product->regular_price < $product->sale_price ) { + $product->sale_price = ''; + update_post_meta( $product->id, '_sale_price', '' ); + } + + if ( $product->sale_price ) { + update_post_meta( $product->id, '_price', $product->sale_price ); + } else { + update_post_meta( $product->id, '_price', $product->regular_price ); + } + } + } + + /** + * Save a variable subscription's details when the edit product page is submitted for a variable + * subscription product type (or the bulk edit product is saved). + * + * @param int $post_id ID of the parent WC_Product_Variable_Subscription + * @return null + * @since 1.3 + */ + public static function process_product_meta_variable_subscription( $post_id ) { + + if ( ! WC_Subscriptions_Product::is_subscription( $post_id ) || empty( $_POST['_wcsnonce_save_variations'] ) || ! wp_verify_nonce( $_POST['_wcsnonce_save_variations'], 'wcs_subscription_variations' ) ) { + return; + } + + // Make sure WooCommerce calculates correct prices + $_POST['variable_regular_price'] = isset( $_POST['variable_subscription_price'] ) ? $_POST['variable_subscription_price'] : 0; + + // Run WooCommerce core saving routine for WC < 2.4 + if ( ! is_ajax() ) { + WC_Meta_Box_Product_Data::save_variations( $post_id, get_post( $post_id ) ); + } + + if ( ! isset( $_REQUEST['variable_post_id'] ) ) { + return; + } + + $variable_post_ids = $_POST['variable_post_id']; + + $max_loop = max( array_keys( $variable_post_ids ) ); + + // Save each variations details + for ( $i = 0; $i <= $max_loop; $i ++ ) { + + if ( ! isset( $variable_post_ids[ $i ] ) ) { + continue; + } + + $variation_id = absint( $variable_post_ids[ $i ] ); + + if ( isset( $_POST['variable_subscription_price'] ) && is_array( $_POST['variable_subscription_price'] ) ) { + $subscription_price = wc_format_decimal( $_POST['variable_subscription_price'][ $i ] ); + update_post_meta( $variation_id, '_subscription_price', $subscription_price ); + update_post_meta( $variation_id, '_regular_price', $subscription_price ); + } + + // Make sure trial period is within allowable range + $subscription_ranges = wcs_get_subscription_ranges(); + + $max_trial_length = count( $subscription_ranges[ $_POST['variable_subscription_trial_period'][ $i ] ] ) - 1; + + $_POST['variable_subscription_trial_length'][ $i ] = absint( $_POST['variable_subscription_trial_length'][ $i ] ); + + if ( $_POST['variable_subscription_trial_length'][ $i ] > $max_trial_length ) { + $_POST['variable_subscription_trial_length'][ $i ] = $max_trial_length; + } + + // Work around a WPML bug which means 'variable_subscription_trial_period' is not set when using "Edit Product" as the product translation interface + if ( $_POST['variable_subscription_trial_length'][ $i ] < 0 ) { + $_POST['variable_subscription_trial_length'][ $i ] = 0; + } + + $subscription_fields = array( + '_subscription_sign_up_fee', + '_subscription_period', + '_subscription_period_interval', + '_subscription_length', + '_subscription_trial_period', + '_subscription_trial_length', + ); + + foreach ( $subscription_fields as $field_name ) { + if ( isset( $_POST[ 'variable' . $field_name ][ $i ] ) ) { + update_post_meta( $variation_id, $field_name, wc_clean( $_POST[ 'variable' . $field_name ][ $i ] ) ); + } + } + } + + // Now that all the variation's meta is saved, sync the min variation price + $variable_subscription = wc_get_product( $post_id ); + $variable_subscription->variable_product_sync(); + + } + + /** + * Set default values for subscription dropdown fields when bulk adding variations to fix issue #1342 + * + * @param int $variation_id ID the post_id of the variation being added + * @return null + */ + public static function set_variation_meta_defaults_on_bulk_add( $variation_id ) { + + if ( ! empty( $variation_id ) ) { + update_post_meta( $variation_id, '_subscription_period', 'month' ); + update_post_meta( $variation_id, '_subscription_period_interval', '1' ); + update_post_meta( $variation_id, '_subscription_length', '0' ); + update_post_meta( $variation_id, '_subscription_trial_period', 'month' ); + } + } + + /** + * Adds all necessary admin styles. + * + * @param array Array of Product types & their labels, excluding the Subscription product type. + * @return array Array of Product types & their labels, including the Subscription product type. + * @since 1.0 + */ + public static function enqueue_styles_scripts() { + global $post; + + // Get admin screen id + $screen = get_current_screen(); + + $is_woocommerce_screen = ( in_array( $screen->id, array( 'product', 'edit-shop_order', 'shop_order', 'edit-shop_subscription', 'shop_subscription', 'users', 'woocommerce_page_wc-settings' ) ) ) ? true : false; + $is_activation_screen = ( get_transient( WC_Subscriptions::$activation_transient ) == true ) ? true : false; + + if ( $is_woocommerce_screen ) { + + $dependencies = array( 'jquery' ); + + $woocommerce_admin_script_handle = 'wc-admin-meta-boxes'; + + if ( $screen->id == 'product' ) { + $dependencies[] = $woocommerce_admin_script_handle; + $dependencies[] = 'wc-admin-product-meta-boxes'; + $dependencies[] = 'wc-admin-variation-meta-boxes'; + + $script_params = array( + 'productType' => WC_Subscriptions::$name, + 'trialPeriodSingular' => wcs_get_available_time_periods(), + 'trialPeriodPlurals' => wcs_get_available_time_periods( 'plural' ), + 'subscriptionLengths' => wcs_get_subscription_ranges(), + 'trialTooLongMessages' => self::get_trial_period_validation_message( 'separate' ), + 'bulkEditPeriodMessage' => __( 'Enter the new period, either day, week, month or year:', 'woocommerce-subscriptions' ), + 'bulkEditLengthMessage' => __( 'Enter a new length (e.g. 5):', 'woocommerce-subscriptions' ), + 'bulkEditIntervalhMessage' => __( 'Enter a new interval as a single number (e.g. to charge every 2nd month, enter 2):', 'woocommerce-subscriptions' ), + ); + } else if ( 'edit-shop_order' == $screen->id ) { + $script_params = array( + 'bulkTrashWarning' => __( "You are about to trash one or more orders which contain a subscription.\n\nTrashing the orders will also trash the subscriptions purchased with these orders.", 'woocommerce-subscriptions' ), + ); + } else if ( 'shop_order' == $screen->id ) { + $dependencies[] = $woocommerce_admin_script_handle; + $dependencies[] = 'wc-admin-order-meta-boxes'; + + if ( WC_Subscriptions::is_woocommerce_pre( '2.6' ) ) { + $dependencies[] = 'wc-admin-order-meta-boxes-modal'; + } + + $script_params = array( + 'bulkTrashWarning' => __( 'Trashing this order will also trash the subscription purchased with the order.', 'woocommerce-subscriptions' ), + 'changeMetaWarning' => __( "WARNING: Bad things are about to happen!\n\nThe payment gateway used to purchase this subscription does not support modifying a subscription's details.\n\nChanges to the billing period, recurring discount, recurring tax or recurring total may not be reflected in the amount charged by the payment gateway.", 'woocommerce-subscriptions' ), + 'removeItemWarning' => __( 'You are deleting a subscription item. You will also need to manually cancel and trash the subscription on the Manage Subscriptions screen.', 'woocommerce-subscriptions' ), + 'roundAtSubtotal' => esc_attr( get_option( 'woocommerce_tax_round_at_subtotal' ) ), + 'EditOrderNonce' => wp_create_nonce( 'woocommerce-subscriptions' ), + 'postId' => $post->ID, + ); + } else if ( 'users' == $screen->id ) { + $script_params = array( + 'deleteUserWarning' => __( "Warning: Deleting a user will also delete the user's subscriptions. The user's orders will remain but be reassigned to the 'Guest' user.\n\nDo you want to continue to delete this user and any associated subscriptions?", 'woocommerce-subscriptions' ), + ); + } + + $script_params['ajaxLoaderImage'] = WC()->plugin_url() . '/assets/images/ajax-loader.gif'; + $script_params['ajaxUrl'] = admin_url( 'admin-ajax.php' ); + $script_params['isWCPre24'] = var_export( WC_Subscriptions::is_woocommerce_pre( '2.4' ), true ); + + wp_enqueue_script( 'woocommerce_subscriptions_admin', plugin_dir_url( WC_Subscriptions::$plugin_file ) . 'assets/js/admin/admin.js', $dependencies, filemtime( plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'assets/js/admin/admin.js' ) ); + wp_localize_script( 'woocommerce_subscriptions_admin', 'WCSubscriptions', apply_filters( 'woocommerce_subscriptions_admin_script_parameters', $script_params ) ); + + // Maybe add the pointers for first timers + if ( isset( $_GET['subscription_pointers'] ) && self::show_user_pointers() ) { + + $dependencies[] = 'wp-pointer'; + + $pointer_script_params = array( + // translators: placeholders are for HTML tags. They are 1$: "

    ", 2$: "

    ", 3$: "

    ", 4$: "", 5$: "", 6$: "", 7$: "", 8$: "

    " + 'typePointerContent' => sprintf( _x( '%1$sChoose Subscription%2$s%3$sThe WooCommerce Subscriptions extension adds two new subscription product types - %4$sSimple subscription%5$s and %6$sVariable subscription%7$s.%8$s', 'used in admin pointer script params in javascript as type pointer content', 'woocommerce-subscriptions' ), '

    ', '

    ', '

    ', '', '', '', '', '

    ' ), + // translators: placeholders are for HTML tags. They are 1$: "

    ", 2$: "

    ", 3$: "

    ", 4$: "

    " + 'pricePointerContent' => sprintf( _x( '%1$sSet a Price%2$s%3$sSubscription prices are a little different to other product prices. For a subscription, you can set a billing period, length, sign-up fee and free trial.%4$s', 'used in admin pointer script params in javascript as price pointer content', 'woocommerce-subscriptions' ), '

    ', '

    ', '

    ', '

    ' ), + ); + + wp_enqueue_script( 'woocommerce_subscriptions_admin_pointers', plugin_dir_url( WC_Subscriptions::$plugin_file ) . 'assets/js/admin/admin-pointers.js', $dependencies, WC_Subscriptions::$version ); + + wp_localize_script( 'woocommerce_subscriptions_admin_pointers', 'WCSPointers', apply_filters( 'woocommerce_subscriptions_admin_pointer_script_parameters', $pointer_script_params ) ); + + wp_enqueue_style( 'wp-pointer' ); + } + } + + // Maybe add the admin notice + if ( $is_activation_screen ) { + + $woocommerce_plugin_dir_file = self::get_woocommerce_plugin_dir_file(); + + if ( ! empty( $woocommerce_plugin_dir_file ) ) { + + wp_enqueue_style( 'woocommerce-activation', plugins_url( '/assets/css/activation.css', self::get_woocommerce_plugin_dir_file() ), array(), WC_Subscriptions::$version ); + + if ( ! isset( $_GET['page'] ) || 'wcs-about' != $_GET['page'] ) { + add_action( 'admin_notices', __CLASS__ . '::admin_installed_notice' ); + } + } + delete_transient( WC_Subscriptions::$activation_transient ); + } + + if ( $is_woocommerce_screen || $is_activation_screen ) { + wp_enqueue_style( 'woocommerce_admin_styles', WC()->plugin_url() . '/assets/css/admin.css', array(), WC_Subscriptions::$version ); + wp_enqueue_style( 'woocommerce_subscriptions_admin', plugin_dir_url( WC_Subscriptions::$plugin_file ) . 'assets/css/admin.css', array( 'woocommerce_admin_styles' ), WC_Subscriptions::$version ); + } + + } + + /** + * Add the "Active Subscriber?" column to the User's admin table + */ + public static function add_user_columns( $columns ) { + + if ( current_user_can( 'manage_woocommerce' ) ) { + // Move Active Subscriber before Orders for aesthetics + $last_column = array_slice( $columns, -1, 1, true ); + array_pop( $columns ); + $columns['woocommerce_active_subscriber'] = __( 'Active Subscriber?', 'woocommerce-subscriptions' ); + $columns += $last_column; + } + + return $columns; + } + + /** + * Hooked to the users table to display a check mark if a given user has an active subscription. + * + * @param string $value The string to output in the column specified with $column_name + * @param string $column_name The string key for the current column in an admin table + * @param int $user_id The ID of the user to which this row relates + * @return string $value A check mark if the column is the active_subscriber column and the user has an active subscription. + * @since 1.0 + */ + public static function user_column_values( $value, $column_name, $user_id ) { + + if ( 'woocommerce_active_subscriber' == $column_name ) { + if ( wcs_user_has_subscription( $user_id, '', 'active' ) ) { + $value = '
    '; + } else { + $value = '
    -
    '; + } + } + + return $value; + } + + + /** + * Outputs the Subscription Management admin page with a sortable @see WC_Subscriptions_List_Table used to + * display all the subscriptions that have been purchased. + * + * @uses WC_Subscriptions_List_Table + * @since 1.0 + */ + public static function subscriptions_management_page() { + + $subscriptions_table = self::get_subscriptions_list_table(); + $subscriptions_table->prepare_items(); ?> +
    +

    +

    + messages(); ?> + views(); ?> + +
    + display(); ?> +
    +
    + __( 'Subscriptions', 'woocommerce-subscriptions' ), + 'default' => 10, + 'option' => self::$option_prefix . '_admin_per_page', + ) + ); + } + + /** + * Sets the correct value for screen options on the Subscription Management admin page. + * + * @since 1.3.1 + */ + public static function set_manage_subscriptions_screen_option( $status, $option, $value ) { + + if ( self::$option_prefix . '_admin_per_page' == $option ) { + return $value; + } + + return $status; + } + + /** + * Returns the columns for the Manage Subscriptions table, specifically used for adding the + * show/hide column screen options. + * + * @since 1.3.1 + */ + public static function get_subscription_table_columns( $columns ) { + + $subscriptions_table = self::get_subscriptions_list_table(); + + return array_merge( $subscriptions_table->get_columns(), $columns ); + } + + /** + * Returns the columns for the Manage Subscriptions table, specifically used for adding the + * show/hide column screen options. + * + * @since 1.3.1 + */ + public static function get_subscriptions_list_table() { + + if ( ! isset( self::$subscriptions_list_table ) ) { + + if ( ! class_exists( 'WC_Subscriptions_List_Table' ) ) { + require_once( 'class-wc-subscriptions-list-table.php' ); + } + + self::$subscriptions_list_table = new WC_Subscriptions_List_Table(); + } + + return self::$subscriptions_list_table; + } + + /** + * Uses the WooCommerce options API to save settings via the @see woocommerce_update_options() function. + * + * @uses woocommerce_update_options() + * @uses self::get_settings() + * @since 1.0 + */ + public static function update_subscription_settings() { + + if ( empty( $_POST['_wcsnonce'] ) || ! wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_subscription_settings' ) ) { + return; + } + + // Make sure automatic payments are on when manual renewals are switched off + if ( ! isset( $_POST[ self::$option_prefix . '_accept_manual_renewals' ] ) && isset( $_POST[ self::$option_prefix . '_turn_off_automatic_payments' ] ) ) { + unset( $_POST[ self::$option_prefix . '_turn_off_automatic_payments' ] ); + } + + woocommerce_update_options( self::get_settings() ); + } + + /** + * Uses the WooCommerce admin fields API to output settings via the @see woocommerce_admin_fields() function. + * + * @uses woocommerce_admin_fields() + * @uses self::get_settings() + * @since 1.0 + */ + public static function subscription_settings_page() { + woocommerce_admin_fields( self::get_settings() ); + wp_nonce_field( 'wcs_subscription_settings', '_wcsnonce', false ); + } + + /** + * Add the Subscriptions settings tab to the WooCommerce settings tabs array. + * + * @param array $settings_tabs Array of WooCommerce setting tabs & their labels, excluding the Subscription tab. + * @return array $settings_tabs Array of WooCommerce setting tabs & their labels, including the Subscription tab. + * @since 1.0 + */ + public static function add_subscription_settings_tab( $settings_tabs ) { + + $settings_tabs[ self::$tab_name ] = __( 'Subscriptions', 'woocommerce-subscriptions' ); + + return $settings_tabs; + } + + /** + * Sets default values for all the WooCommerce Subscription options. Called on plugin activation. + * + * @see WC_Subscriptions::activate_woocommerce_subscriptions + * @since 1.0 + */ + public static function add_default_settings() { + foreach ( self::get_settings() as $setting ) { + if ( isset( $setting['default'] ) ) { + add_option( $setting['id'], $setting['default'] ); + } + } + + } + + /** + * Get all the settings for the Subscriptions extension in the format required by the @see woocommerce_admin_fields() function. + * + * @return array Array of settings in the format required by the @see woocommerce_admin_fields() function. + * @since 1.0 + */ + public static function get_settings() { + if ( ! function_exists( 'get_editable_roles' ) ) { + require_once( ABSPATH . 'wp-admin/includes/user.php' ); + } + + $roles = get_editable_roles(); + + foreach ( $roles as $role => $details ) { + $roles_options[ $role ] = translate_user_role( $details['name'] ); + } + + $available_gateways = array(); + + foreach ( WC()->payment_gateways->payment_gateways() as $gateway ) { + if ( $gateway->supports( 'subscriptions' ) ) { + $available_gateways[] = sprintf( '%s [%s]', $gateway->title, $gateway->id ); + } + } + + if ( count( $available_gateways ) == 0 ) { + // translators: $1-2: opening and closing tags of a link that takes to PayPal settings, $3-4: opening and closing tags of a link that takes to Woo marketplace / Stripe product page + $available_gateways_description = sprintf( __( 'No payment gateways capable of processing automatic subscription payments are enabled. Please enable the %1$sPayPal Standard%2$s gateway or get the %3$sfree Stripe extension%4$s if you want to process automatic payments.', 'woocommerce-subscriptions' ), '', '', '', '' ); + } elseif ( count( $available_gateways ) == 1 ) { + // translators: placeholder is name of a gateway + $available_gateways_description = sprintf( __( 'The %s gateway can process automatic subscription payments.', 'woocommerce-subscriptions' ), '' . $available_gateways[0] . '' ); + } elseif ( count( $available_gateways ) > 1 ) { + // translators: %1$s - a comma separated list of gateway names (e.g. "stripe, paypal, worldpay"), %2$s - one name of gateway (e.g. "authorize.net") + $available_gateways_description = sprintf( __( 'The %1$s & %2$s gateways can process automatic subscription payments.', 'woocommerce-subscriptions' ), '' . implode( ', ', array_slice( $available_gateways, 0, count( $available_gateways ) - 1 ) ) . '', '' . array_pop( $available_gateways ) . '' ); + } + + return apply_filters( 'woocommerce_subscription_settings', array( + + array( + 'name' => __( 'Button Text', 'woocommerce-subscriptions' ), + 'type' => 'title', + 'desc' => '', + 'id' => self::$option_prefix . '_button_text', + ), + + array( + 'name' => __( 'Add to Cart Button Text', 'woocommerce-subscriptions' ), + 'desc' => __( 'A product displays a button with the text "Add to Cart". By default, a subscription changes this to "Sign Up Now". You can customise the button text for subscriptions here.', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => self::$option_prefix . '_add_to_cart_button_text', + 'css' => 'min-width:150px;', + 'default' => __( 'Sign Up Now', 'woocommerce-subscriptions' ), + 'type' => 'text', + 'desc_tip' => true, + ), + + array( + 'name' => __( 'Place Order Button Text', 'woocommerce-subscriptions' ), + 'desc' => __( 'Use this field to customise the text displayed on the checkout button when an order contains a subscription. Normally the checkout submission button displays "Place Order". When the cart contains a subscription, this is changed to "Sign Up Now".', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => self::$option_prefix . '_order_button_text', + 'css' => 'min-width:150px;', + 'default' => __( 'Sign Up Now', 'woocommerce-subscriptions' ), + 'type' => 'text', + 'desc_tip' => true, + ), + + array( 'type' => 'sectionend', 'id' => self::$option_prefix . '_button_text' ), + + array( + 'name' => __( 'Roles', 'woocommerce-subscriptions' ), + 'type' => 'title', + // translators: placeholders are tags + 'desc' => sprintf( __( 'Choose the default roles to assign to active and inactive subscribers. For record keeping purposes, a user account must be created for subscribers. Users with the %sadministrator%s role, such as yourself, will never be allocated these roles to prevent locking out administrators.', 'woocommerce-subscriptions' ), '', '' ), + 'id' => self::$option_prefix . '_role_options', + ), + + array( + 'name' => __( 'Subscriber Default Role', 'woocommerce-subscriptions' ), + 'desc' => __( 'When a subscription is activated, either manually or after a successful purchase, new users will be assigned this role.', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => self::$option_prefix . '_subscriber_role', + 'css' => 'min-width:150px;', + 'default' => 'subscriber', + 'type' => 'select', + 'options' => $roles_options, + 'desc_tip' => true, + ), + + array( + 'name' => __( 'Inactive Subscriber Role', 'woocommerce-subscriptions' ), + 'desc' => __( 'If a subscriber\'s subscription is manually cancelled or expires, she will be assigned this role.', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => self::$option_prefix . '_cancelled_role', + 'css' => 'min-width:150px;', + 'default' => 'customer', + 'type' => 'select', + 'options' => $roles_options, + 'desc_tip' => true, + ), + + array( 'type' => 'sectionend', 'id' => self::$option_prefix . '_role_options' ), + + array( + 'name' => _x( 'Renewals', 'option section heading', 'woocommerce-subscriptions' ), + 'type' => 'title', + 'desc' => '', + 'id' => self::$option_prefix . '_renewal_options', + ), + + array( + 'name' => __( 'Manual Renewal Payments', 'woocommerce-subscriptions' ), + 'desc' => __( 'Accept Manual Renewals', 'woocommerce-subscriptions' ), + 'id' => self::$option_prefix . '_accept_manual_renewals', + 'default' => 'no', + 'type' => 'checkbox', + // translators: placeholders are opening and closing link tags + 'desc_tip' => sprintf( __( 'With manual renewals, a customer\'s subscription is put on-hold until they login and pay to renew it. %sLearn more%s.', 'woocommerce-subscriptions' ), '', '' ), + 'checkboxgroup' => 'start', + 'show_if_checked' => 'option', + ), + + array( + 'desc' => __( 'Turn off Automatic Payments', 'woocommerce-subscriptions' ), + 'id' => self::$option_prefix . '_turn_off_automatic_payments', + 'default' => 'no', + 'type' => 'checkbox', + // translators: placeholders are opening and closing link tags + 'desc_tip' => sprintf( __( 'If you never want a customer to be automatically charged for a subscription renewal payment, you can turn off automatic payments completely. %sLearn more%s.', 'woocommerce-subscriptions' ), '', '' ), + 'checkboxgroup' => 'end', + 'show_if_checked' => 'yes', + ), + + array( 'type' => 'sectionend', 'id' => self::$option_prefix . '_renewal_options' ), + + array( + 'name' => _x( 'Miscellaneous', 'options section heading', 'woocommerce-subscriptions' ), + 'type' => 'title', + 'desc' => '', + 'id' => self::$option_prefix . '_miscellaneous', + ), + + array( + 'name' => __( 'Customer Suspensions', 'woocommerce-subscriptions' ), + 'desc' => _x( 'suspensions per billing period.', 'there\'s a number immediately in front of this text', 'woocommerce-subscriptions' ), + 'id' => self::$option_prefix . '_max_customer_suspensions', + 'css' => 'min-width:50px;', + 'default' => 0, + 'type' => 'select', + 'options' => apply_filters( 'woocommerce_subscriptions_max_customer_suspension_range', array_merge( range( 0, 12 ), array( 'unlimited' => 'Unlimited' ) ) ), + 'desc_tip' => __( 'Set a maximum number of times a customer can suspend their account for each billing period. For example, for a value of 3 and a subscription billed yearly, if the customer has suspended their account 3 times, they will not be presented with the option to suspend their account until the next year. Store managers will always be able able to suspend an active subscription. Set this to 0 to turn off the customer suspension feature completely.', 'woocommerce-subscriptions' ), + ), + + array( + 'name' => __( 'Mixed Checkout', 'woocommerce-subscriptions' ), + 'desc' => __( 'Allow subscriptions and products to be purchased simultaneously.', 'woocommerce-subscriptions' ), + 'id' => self::$option_prefix . '_multiple_purchase', + 'default' => 'no', + 'type' => 'checkbox', + 'desc_tip' => __( 'Allow subscriptions and products to be purchased in a single transaction.', 'woocommerce-subscriptions' ), + ), + + array( + 'name' => __( 'Drip Downloadable Content', 'woocommerce-subscriptions' ), + 'desc' => __( 'Enable dripping for downloadable content on subscription products.', 'woocommerce-subscriptions' ), + 'id' => self::$option_prefix . '_drip_downloadable_content_on_renewal', + 'default' => 'no', + 'type' => 'checkbox', + 'desc_tip' => sprintf( __( 'Enabling this grants access to new downloadable files added to a product only after the next renewal is processed.%sBy default, access to new downloadable files added to a product is granted immediately to any customer that has an active subscription with that product.', 'woocommerce-subscriptions' ), '
    ' ), + ), + + array( 'type' => 'sectionend', 'id' => self::$option_prefix . '_miscellaneous' ), + + array( + 'name' => __( 'Payment Gateways', 'woocommerce-subscriptions' ), + 'desc' => $available_gateways_description, + 'id' => self::$option_prefix . '_payment_gateways_available', + 'type' => 'informational', + ), + + array( + // translators: placeholders are opening and closing link tags + 'desc' => sprintf( __( 'Other payment gateways can be used to process %smanual subscription renewal payments%s only.', 'woocommerce-subscriptions' ), '', '' ), + 'id' => self::$option_prefix . '_payment_gateways_additional', + 'type' => 'informational', + ), + + array( + // translators: $1-$2: opening and closing tags. Link to documents->payment gateways, 3$-4$: opening and closing tags. Link to woothemes extensions shop page + 'desc' => sprintf( __( 'Find new gateways that %1$ssupport automatic subscription payments%2$s in the official %3$sWooCommerce Marketplace%4$s.', 'woocommerce-subscriptions' ), '', '', '', '' ), + 'id' => self::$option_prefix . '_payment_gateways_additional', + 'type' => 'informational', + ), + + ) ); + + } + + /** + * Displays instructional information for a WooCommerce setting. + * + * @since 1.0 + */ + public static function add_informational_admin_field( $field_details ) { + + if ( isset( $field_details['name'] ) && $field_details['name'] ) { + echo '

    ' . esc_html( $field_details['name'] ) . '

    '; + } + + if ( isset( $field_details['desc'] ) && $field_details['desc'] ) { + echo wp_kses_post( wpautop( wptexturize( $field_details['desc'] ) ) ); + } + } + + /** + * Outputs a welcome message. Called when the Subscriptions extension is activated. + * + * @since 1.0 + */ + public static function admin_installed_notice() { + ?> +
    +
    +

    + tags, $3-$4: opening and closing tags + echo wp_kses( sprintf( __( '%1$sWooCommerce Subscriptions Installed%2$s – %3$sYou\'re ready to start selling subscriptions!%4$s', 'woocommerce-subscriptions' ), '', '', '', '' ), array( 'strong' => true, 'em' => true ) ); + ?> +

    + +

    + + + + +

    +
    +
    + 0 ) { + + $subscription_id = absint( $_GET['_subscription_related_orders'] ); + + $subscription = wcs_get_subscription( $subscription_id ); + + if ( ! wcs_is_subscription( $subscription ) ) { + // translators: placeholder is a number + wcs_add_admin_notice( sprintf( __( 'We can\'t find a subscription with ID #%d. Perhaps it was deleted?', 'woocommerce-subscriptions' ), $subscription_id ), 'error' ); + $where .= " AND {$wpdb->posts}.ID = 0"; + } else { + self::$found_related_orders = true; + $where .= sprintf( " AND {$wpdb->posts}.ID IN (%s)", implode( ',', array_map( 'absint', array_unique( $subscription->get_related_orders( 'ids' ) ) ) ) ); + } + } + } + + return $where; + } + + /** + * Display a notice indicating that the "Orders" list is filtered. + * @see self::filter_orders() + */ + public static function display_renewal_filter_notice() { + + global $wp_version; + + $query_arg = '_subscription_related_orders'; + + if ( isset( $_GET[ $query_arg ] ) && $_GET[ $query_arg ] > 0 && true === self::$found_related_orders ) { + + $initial_order = new WC_Order( absint( $_GET[ $query_arg ] ) ); + + if ( version_compare( $wp_version, '4.2', '<' ) ) { + echo '

    '; + printf( + '×', + esc_url( remove_query_arg( $query_arg ) ) + ); + // translators: placeholders are opening link tag, ID of sub, and closing link tag + printf( esc_html__( 'Showing orders for %sSubscription %s%s', 'woocommerce-subscriptions' ), '', esc_html( $initial_order->get_order_number() ), '' ); + echo '

    '; + } else { + echo ''; + } + } + + /** + * Returns either a string or array of strings describing the allowable trial period range + * for a subscription. + * + * @since 1.0 + */ + public static function get_trial_period_validation_message( $form = 'combined' ) { + + $subscription_ranges = wcs_get_subscription_ranges(); + + if ( 'combined' == $form ) { + // translators: number of 1$: days, 2$: weeks, 3$: months, 4$: years + $error_message = sprintf( __( 'The trial period can not exceed: %1s, %2s, %3s or %4s.', 'woocommerce-subscriptions' ), array_pop( $subscription_ranges['day'] ), array_pop( $subscription_ranges['week'] ), array_pop( $subscription_ranges['month'] ), array_pop( $subscription_ranges['year'] ) ); + } else { + $error_message = array(); + foreach ( wcs_get_available_time_periods() as $period => $string ) { + // translators: placeholder is a time period (e.g. "4 weeks") + $error_message[ $period ] = sprintf( __( 'The trial period can not exceed %s.', 'woocommerce-subscriptions' ), array_pop( $subscription_ranges[ $period ] ) ); + } + } + + return apply_filters( 'woocommerce_subscriptions_trial_period_validation_message', $error_message ); + } + + /** + * Callback for the [subscriptions] shortcode that displays subscription names for a particular user. + * + * @param array $attributes Shortcode attributes. + * @return string + */ + public static function do_subscriptions_shortcode( $attributes ) { + $attributes = wp_parse_args( + $attributes, + array( + 'user_id' => 0, + 'status' => 'active', + ) + ); + + $subscriptions = wcs_get_users_subscriptions( $attributes['user_id'] ); + + if ( empty( $subscriptions ) ) { + return '
      +
    • No subscriptions found.
    • +
    '; + } + + $list = '
      '; + + foreach ( $subscriptions as $subscription ) { + if ( 'all' == $attributes['status'] || $subscription->has_status( $attributes['status'] ) ) { + $list .= sprintf( '
    • Subscription %s
    • ', $subscription->get_view_order_url(), $subscription->get_order_number() ); + } + } + $list .= '
    '; + + return $list; + } + + /** + * Adds Subscriptions specific details to the WooCommerce System Status report. + * + * @param array $attributes Shortcode attributes. + * @return array + */ + public static function add_system_status_items( $debug_data ) { + + $is_wcs_debug = defined( 'WCS_DEBUG' ) ? WCS_DEBUG : false; + + $debug_data['wcs_debug'] = array( + 'name' => _x( 'WCS_DEBUG', 'label that indicates whether debugging is turned on for the plugin', 'woocommerce-subscriptions' ), + 'note' => ( $is_wcs_debug ) ? __( 'Yes', 'woocommerce-subscriptions' ) : __( 'No', 'woocommerce-subscriptions' ), + 'success' => $is_wcs_debug ? 0 : 1, + ); + + $debug_data['wcs_staging'] = array( + 'name' => _x( 'Subscriptions Mode', 'Live or Staging, Label on WooCommerce -> System Status page', 'woocommerce-subscriptions' ), + 'note' => '' . ( ( WC_Subscriptions::is_duplicate_site() ) ? _x( 'Staging', 'refers to staging site', 'woocommerce-subscriptions' ) : _x( 'Live', 'refers to live site', 'woocommerce-subscriptions' ) ) . '', + 'success' => ( WC_Subscriptions::is_duplicate_site() ) ? 0 : 1, + ); + + return $debug_data; + } + + /** + * A WooCommerce version aware function for getting the Subscriptions admin settings + * tab URL. + * + * @since 1.4.5 + * @return string + */ + public static function settings_tab_url() { + + $settings_tab_url = admin_url( 'admin.php?page=wc-settings&tab=subscriptions' ); + + return apply_filters( 'woocommerce_subscriptions_settings_tab_url', $settings_tab_url ); + } + + /** + * Add a column to the Payment Gateway table to show whether the gateway supports automated renewals. + * + * @since 1.5 + * @return string + */ + public static function payment_gateways_rewewal_column( $header ) { + + $header_new = array_slice( $header, 0, count( $header ) - 1, true ) + + array( 'renewals' => __( 'Automatic Recurring Payments', 'woocommerce-subscriptions' ) ) + // Ideally, we could add a link to the docs here, but the title is passed through esc_html() + array_slice( $header, count( $header ) - 1, count( $header ) - ( count( $header ) - 1 ), true ); + + return $header_new; + } + + /** + * Check whether the payment gateway passed in supports automated renewals or not. + * Automatically flag support for Paypal since it is included with subscriptions. + * Display in the Payment Gateway column. + * + * @since 1.5 + */ + public static function payment_gateways_rewewal_support( $gateway ) { + + echo ''; + if ( ( is_array( $gateway->supports ) && in_array( 'subscriptions', $gateway->supports ) ) || $gateway->id == 'paypal' ) { + $status_html = '' . esc_html__( 'Yes', 'woocommerce-subscriptions' ) . ''; + } else { + $status_html = '-'; + } + + $allowed_html = wp_kses_allowed_html( 'post' ); + $allowed_html['span']['data-tip'] = true; + + /** + * Automatic Renewal Payments Support Status HTML Filter. + * + * @since 2.0 + * @param string $status_html + * @param \WC_Payment_Gateway $gateway + */ + echo wp_kses( apply_filters( 'woocommerce_payment_gateways_renewal_support_status_html', $status_html, $gateway ), $allowed_html ); + + echo ''; + } + + /** + * Do not display formatted order total on the Edit Order administration screen + * + * @since 1.5.17 + */ + public static function maybe_remove_formatted_order_total_filter( $formatted_total, $order ) { + + // Check if we're on the Edit Order screen - get_current_screen() only exists on admin pages so order of operations matters here + if ( is_admin() && function_exists( 'get_current_screen' ) ) { + + $screen = get_current_screen(); + + if ( is_object( $screen ) && 'shop_order' == $screen->id ) { + remove_filter( 'woocommerce_get_formatted_order_total', 'WC_Subscriptions_Order::get_formatted_order_total', 10, 2 ); + } + } + + return $formatted_total; + } + + /** + * Deprecated due to new meta boxes required for WC 2.2. + * + * @deprecated 1.5.10 + */ + public static function add_related_orders_meta_box() { + _deprecated_function( __METHOD__, '1.5.10', __CLASS__ . '::add_meta_boxes()' ); + self::add_meta_boxes(); + } + + /** + * Outputs the contents of the "Renewal Orders" meta box. + * + * @param object $post Current post data. + */ + public static function related_orders_meta_box( $post ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Meta_Box_Related_Orders::output()' ); + WCS_Meta_Box_Related_Orders::output(); + } + + /** + * Add users with subscriptions to the "Customers" report in WooCommerce -> Reports. + * + * @param WP_User_Query $user_query + */ + public static function add_subscribers_to_customers( $user_query ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Set a translation safe screen ID for Subcsription + * + * @since 1.3.3 + */ + public static function set_admin_screen_id() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Once we have set a correct admin page screen ID, we can use it for adding the Manage Subscriptions table's columns. + * + * @since 1.3.3 + */ + public static function add_subscriptions_table_column_filter() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Removes anything that's not a digit or a dot from a string. Sadly it assumes that the decimal separator is a dot. + * That however can be changed in WooCommerce settings, surfacing bugs such as 9,90 becoming 990, a hundred fold + * increase. Use wc_format_decimal instead. + * + * Left in for backward compatibility reasons. + * + * @deprecated 1.5.24 + */ + private static function clean_number( $number ) { + _deprecated_function( __METHOD__, '1.5.23', 'wc_format_decimal()' ); + + $number = preg_replace( '/[^0-9\.]/', '', $number ); + + return $number; + } + + /** + * Filter the "Orders" list to show only renewal orders associated with a specific parent order. + * + * @param array $request + * @return array + */ + public static function filter_orders_by_renewal_parent( $request ) { + _deprecated_function( __METHOD__, '2.0' ); + return $request; + } + + /** + * Registers the "Renewal Orders" meta box for the "Edit Order" page. + * @deprecated 2.0 + */ + public static function add_meta_boxes() { + _deprecated_function( __METHOD__, '2.0', 'WCS_Admin_Meta_Boxes::add_meta_boxes()' ); + } + + /** + * Output the metabox + */ + public static function recurring_totals_meta_box( $post ) { + _deprecated_function( __METHOD__, '2.0' ); + } +} + +WC_Subscriptions_Admin::init(); diff --git a/includes/admin/class-wcs-admin-meta-boxes.php b/includes/admin/class-wcs-admin-meta-boxes.php new file mode 100644 index 0000000..75c8afb --- /dev/null +++ b/includes/admin/class-wcs-admin-meta-boxes.php @@ -0,0 +1,186 @@ +post_type ) { + remove_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Data::save', 40, 2 ); + } + } + + /** + * Print admin styles/scripts + */ + public function enqueue_styles_scripts() { + global $post; + + // Get admin screen id + $screen = get_current_screen(); + + if ( 'shop_subscription' == $screen->id ) { + + wp_register_script( 'jstz', plugin_dir_url( WC_Subscriptions::$plugin_file ) . '/assets/js/admin/jstz.min.js' ); + + wp_register_script( 'momentjs', plugin_dir_url( WC_Subscriptions::$plugin_file ) . '/assets/js/admin/moment.min.js' ); + + wp_enqueue_script( 'wcs-admin-meta-boxes-subscription', plugin_dir_url( WC_Subscriptions::$plugin_file ) . '/assets/js/admin/meta-boxes-subscription.js', array( 'wc-admin-meta-boxes', 'jstz', 'momentjs' ), WC_VERSION ); + + wp_localize_script( 'wcs-admin-meta-boxes-subscription', 'wcs_admin_meta_boxes', apply_filters( 'woocommerce_subscriptions_admin_meta_boxes_script_parameters', array( + 'i18n_start_date_notice' => __( 'Please enter a start date in the past.', 'woocommerce-subscriptions' ), + 'i18n_past_date_notice' => __( 'Please enter a date at least one hour into the future.', 'woocommerce-subscriptions' ), + 'i18n_next_payment_start_notice' => __( 'Please enter a date after the trial end.', 'woocommerce-subscriptions' ), + 'i18n_next_payment_trial_notice' => __( 'Please enter a date after the start date.', 'woocommerce-subscriptions' ), + 'i18n_trial_end_start_notice' => __( 'Please enter a date after the start date.', 'woocommerce-subscriptions' ), + 'i18n_trial_end_next_notice' => __( 'Please enter a date before the next payment.', 'woocommerce-subscriptions' ), + 'i18n_end_date_notice' => __( 'Please enter a date after the next payment.', 'woocommerce-subscriptions' ), + 'process_renewal_action_warning' => __( "Are you sure you want to process a renewal?\n\nThis will charge the customer and email them the renewal order (if emails are enabled).", 'woocommerce-subscriptions' ), + 'payment_method' => wcs_get_subscription( $post )->payment_method, + 'search_customers_nonce' => wp_create_nonce( 'search-customers' ), + ) ) ); + } + } + + /** + * Adds actions to the admin edit subscriptions page, if the subscription hasn't ended and the payment method supports them. + * + * @param array $actions An array of available actions + * @return array An array of updated actions + * @since 2.0 + */ + public static function add_subscription_actions( $actions ) { + global $theorder; + + if ( wcs_is_subscription( $theorder ) && ! $theorder->has_status( wcs_get_subscription_ended_statuses() ) ) { + + if ( $theorder->payment_method_supports( 'subscription_date_changes' ) && $theorder->has_status( 'active' ) ) { + $actions['wcs_process_renewal'] = esc_html__( 'Process renewal', 'woocommerce-subscriptions' ); + } + + $actions['wcs_create_pending_renewal'] = esc_html__( 'Create pending renewal order', 'woocommerce-subscriptions' ); + } + + return $actions; + } + + /** + * Handles the action request to process a renewal order. + * + * @param array $subscription + * @since 2.0 + */ + public static function process_renewal_action_request( $subscription ) { + do_action( 'woocommerce_scheduled_subscription_payment', $subscription->id ); + $subscription->add_order_note( __( 'Process renewal order action requested by admin.', 'woocommerce-subscriptions' ), false, true ); + } + + /** + * Handles the action request to create a pending renewal order. + * + * @param array $subscription + * @since 2.0 + */ + public static function create_pending_renewal_action_request( $subscription ) { + + $subscription->update_status( 'on-hold' ); + + $renewal_order = wcs_create_renewal_order( $subscription ); + + if ( ! $subscription->is_manual() ) { + $renewal_order->set_payment_method( $subscription->payment_gateway ); + } + + $subscription->add_order_note( __( 'Create pending renewal order requested by admin action.', 'woocommerce-subscriptions' ), false, true ); + } + + /** + * Removes order related emails from the available actions. + * + * @param array $available_emails + * @since 2.0 + */ + public static function remove_order_email_actions( $email_actions ) { + global $theorder; + + if ( wcs_is_subscription( $theorder ) ) { + $email_actions = array(); + } + + return $email_actions; + } +} + +new WCS_Admin_Meta_Boxes(); diff --git a/includes/admin/class-wcs-admin-post-types.php b/includes/admin/class-wcs-admin-post-types.php new file mode 100644 index 0000000..bde3f19 --- /dev/null +++ b/includes/admin/class-wcs-admin-post-types.php @@ -0,0 +1,886 @@ +query['post_type'] ) || 'shop_subscription' !== $query->query['post_type'] ) { + return $pieces; + } + + // we need to name ID again due to name conflict if we don't + $pieces['fields'] .= ", {$wpdb->posts}.ID AS original_id, {$wpdb->posts}.post_parent AS original_parent, CASE (SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_subscription_renewal' AND meta_value = original_id) + WHEN 0 THEN CASE (SELECT COUNT(*) FROM {$wpdb->posts} WHERE ID = original_parent) + WHEN 0 THEN 0 + ELSE (SELECT post_date_gmt FROM {$wpdb->posts} WHERE ID = original_parent) + END + ELSE (SELECT p.post_date_gmt FROM {$wpdb->postmeta} pm LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_subscription_renewal' AND meta_value = original_id ORDER BY p.post_date_gmt DESC LIMIT 1) + END + AS last_payment"; + + $order = strtoupper( $query->query['order'] ); + + $pieces['orderby'] = "CAST(last_payment AS DATETIME) {$order}"; + + return $pieces; + } + + + /** + * Displays the dropdown for the product filter + * @return string the html dropdown element + */ + public function restrict_by_product() { + global $typenow; + + if ( 'shop_subscription' !== $typenow ) { + return; + } + + $product_id = ''; + $product_string = ''; + + if ( ! empty( $_GET['_wcs_product'] ) ) { + $product_id = absint( $_GET['_wcs_product'] ); + $product_string = wc_get_product( $product_id )->get_formatted_name(); + } + + ?> + + _x( 'Activate', 'an action on a subscription', 'woocommerce-subscriptions' ), + 'on-hold' => _x( 'Put on-hold', 'an action on a subscription', 'woocommerce-subscriptions' ), + 'cancelled' => _x( 'Cancel', 'an action on a subscription', 'woocommerce-subscriptions' ), + ) ); + + // No need to display certain bulk actions if we know all the subscriptions on the page have that status already + switch ( $post_status ) { + case 'wc-active' : + unset( $bulk_actions['active'] ); + break; + case 'wc-on-hold' : + unset( $bulk_actions['on-hold'] ); + break; + } + + ?> + + 'shop_subscription', + $report_action => true, + 'ids' => join( ',', $subscription_ids ), + 'error_count' => 0, + ); + + foreach ( $subscription_ids as $subscription_id ) { + $subscription = wcs_get_subscription( $subscription_id ); + $order_note = _x( 'Subscription status changed by bulk edit:', 'Used in order note. Reason why status changed.', 'woocommerce-subscriptions' ); + + try { + + if ( 'cancelled' == $action ) { + $subscription->cancel_order( $order_note ); + } else { + $subscription->update_status( $new_status, $order_note, true ); + } + + // Fire the action hooks + switch ( $action ) { + case 'active' : + case 'on-hold' : + case 'cancelled' : + case 'trash' : + do_action( 'woocommerce_admin_changed_subscription_to_' . $action, $subscription_id ); + break; + } + + $changed++; + + } catch ( Exception $e ) { + $sendback_args['error'] = urlencode( $e->getMessage() ); + $sendback_args['error_count']++; + } + } + + $sendback_args['changed'] = $changed; + $sendback = add_query_arg( $sendback_args, wp_get_referer() ? wp_get_referer() : '' ); + wp_safe_redirect( esc_url_raw( $sendback ) ); + + exit(); + } + + /** + * Show confirmation message that subscription status was changed + */ + public function bulk_admin_notices() { + global $post_type, $pagenow; + + // Bail out if not on shop order list page + if ( 'edit.php' !== $pagenow || 'shop_subscription' !== $post_type ) { + return; + } + + $subscription_statuses = wcs_get_subscription_statuses(); + + // Check if any status changes happened + foreach ( $subscription_statuses as $slug => $name ) { + + if ( isset( $_REQUEST[ 'marked_' . str_replace( 'wc-', '', $slug ) ] ) ) { + + $number = isset( $_REQUEST['changed'] ) ? absint( $_REQUEST['changed'] ) : 0; + + // translators: placeholder is the number of subscriptions updated + $message = sprintf( _n( '%s subscription status changed.', '%s subscription statuses changed.', $number, 'woocommerce-subscriptions' ), number_format_i18n( $number ) ); + echo '

    ' . esc_html( $message ) . '

    '; + + if ( ! empty( $_REQUEST['error_count'] ) ) { + $error_msg = isset( $_REQUEST['error'] ) ? stripslashes( $_REQUEST['error'] ) : ''; + $error_count = isset( $_REQUEST['error_count'] ) ? absint( $_REQUEST['error_count'] ) : 0; + // translators: 1$: is the number of subscriptions not updated, 2$: is the error message + $message = sprintf( _n( '%1$s subscription could not be updated: %2$s', '%1$s subscriptions could not be updated: %2$s', $error_count, 'woocommerce-subscriptions' ), number_format_i18n( $error_count ), $error_msg ); + echo '

    ' . esc_html( $message ) . '

    '; + } + + break; + } + } + } + + /** + * Define custom columns for subscription + * + * Column names that have a corresponding `WC_Order` column use the `order_` prefix here + * to take advantage of core WooCommerce assets, like JS/CSS. + * + * @param array $existing_columns + * @return array + */ + public function shop_subscription_columns( $existing_columns ) { + + $columns = array( + 'cb' => '', + 'status' => __( 'Status', 'woocommerce-subscriptions' ), + 'order_title' => __( 'Subscription', 'woocommerce-subscriptions' ), + 'order_items' => __( 'Items', 'woocommerce-subscriptions' ), + 'recurring_total' => __( 'Total', 'woocommerce-subscriptions' ), + 'start_date' => __( 'Start Date', 'woocommerce-subscriptions' ), + 'trial_end_date' => __( 'Trial End', 'woocommerce-subscriptions' ), + 'next_payment_date' => __( 'Next Payment', 'woocommerce-subscriptions' ), + 'last_payment_date' => __( 'Last Payment', 'woocommerce-subscriptions' ), + 'end_date' => __( 'End Date', 'woocommerce-subscriptions' ), + 'orders' => _x( 'Orders', 'number of orders linked to a subscription', 'woocommerce-subscriptions' ), + ); + + return $columns; + } + + /** + * Output custom columns for subscriptions + * @param string $column + */ + public function render_shop_subscription_columns( $column ) { + global $post, $the_subscription; + + if ( empty( $the_subscription ) || $the_subscription->id != $post->ID ) { + $the_subscription = wcs_get_subscription( $post->ID ); + } + + $column_content = ''; + + switch ( $column ) { + case 'status' : + // The status label + $column_content = sprintf( '%s', sanitize_title( $the_subscription->get_status() ), wcs_get_subscription_status_name( $the_subscription->get_status() ), wcs_get_subscription_status_name( $the_subscription->get_status() ) ); + + // Inline actions + $wp_list_table = _get_list_table( 'WP_Posts_List_Table' ); + $post_type_object = get_post_type_object( $post->post_type ); + + $actions = array(); + + $action_url = add_query_arg( + array( + 'post' => $the_subscription->id, + '_wpnonce' => wp_create_nonce( 'bulk-posts' ), + ) + ); + + if ( isset( $_REQUEST['status'] ) ) { + $action_url = add_query_arg( array( 'status' => $_REQUEST['status'] ), $action_url ); + } + + $all_statuses = array( + 'active' => __( 'Reactivate', 'woocommerce-subscriptions' ), + 'on-hold' => __( 'Suspend', 'woocommerce-subscriptions' ), + 'cancelled' => _x( 'Cancel', 'an action on a subscription', 'woocommerce-subscriptions' ), + 'trash' => __( 'Trash', 'woocommerce-subscriptions' ), + 'deleted' => __( 'Delete Permanently', 'woocommerce-subscriptions' ), + ); + + foreach ( $all_statuses as $status => $label ) { + + if ( $the_subscription->can_be_updated_to( $status ) ) { + + if ( in_array( $status, array( 'trash', 'deleted' ) ) ) { + + if ( current_user_can( $post_type_object->cap->delete_post, $post->ID ) ) { + + if ( 'trash' == $post->post_status ) { + $actions['untrash'] = '' . __( 'Restore', 'woocommerce-subscriptions' ) . ''; + } elseif ( EMPTY_TRASH_DAYS ) { + $actions['trash'] = '' . __( 'Trash', 'woocommerce-subscriptions' ) . ''; + } + + if ( 'trash' == $post->post_status || ! EMPTY_TRASH_DAYS ) { + $actions['delete'] = '' . __( 'Delete Permanently', 'woocommerce-subscriptions' ) . ''; + } + } + } else { + + if ( 'pending-cancel' === $the_subscription->get_status() ) { + $label = __( 'Cancel Now', 'woocommerce-subscriptions' ); + } + + $actions[ $status ] = sprintf( '%s', add_query_arg( 'action', $status, $action_url ), $label ); + + } + } + } + + if ( 'pending' === $the_subscription->get_status() ) { + unset( $actions['active'] ); + unset( $actions['trash'] ); + } elseif ( ! in_array( $the_subscription->get_status(), array( 'cancelled', 'pending-cancel', 'expired', 'switched', 'suspended' ) ) ) { + unset( $actions['trash'] ); + } + + $actions = apply_filters( 'woocommerce_subscription_list_table_actions', $actions, $the_subscription ); + + $column_content .= $wp_list_table->row_actions( $actions ); + + $column_content = apply_filters( 'woocommerce_subscription_list_table_column_status_content', $column_content, $the_subscription, $actions ); + break; + + case 'order_title' : + + $customer_tip = ''; + + if ( $address = $the_subscription->get_formatted_billing_address() ) { + $customer_tip .= _x( 'Billing:', 'meaning billing address', 'woocommerce-subscriptions' ) . ' ' . esc_html( $address ); + } + + if ( $the_subscription->billing_email ) { + // translators: placeholder is customer's billing email + $customer_tip .= '

    ' . sprintf( __( 'Email: %s', 'woocommerce-subscriptions' ), esc_attr( $the_subscription->billing_email ) ); + } + + if ( $the_subscription->billing_phone ) { + // translators: placeholder is customer's billing phone number + $customer_tip .= '

    ' . sprintf( __( 'Tel: %s', 'woocommerce-subscriptions' ), esc_html( $the_subscription->billing_phone ) ); + } + + if ( ! empty( $customer_tip ) ) { + echo '
    '; + } + + // This is to stop PHP from complaining + $username = ''; + + if ( $the_subscription->get_user_id() && ( false !== ( $user_info = get_userdata( $the_subscription->get_user_id() ) ) ) ) { + + $username = ''; + + if ( $the_subscription->billing_first_name || $the_subscription->billing_last_name ) { + $username .= esc_html( ucfirst( $the_subscription->billing_first_name ) . ' ' . ucfirst( $the_subscription->billing_last_name ) ); + } elseif ( $user_info->first_name || $user_info->last_name ) { + $username .= esc_html( ucfirst( $user_info->first_name ) . ' ' . ucfirst( $user_info->last_name ) ); + } else { + $username .= esc_html( ucfirst( $user_info->display_name ) ); + } + + $username .= ''; + + } elseif ( $the_subscription->billing_first_name || $the_subscription->billing_last_name ) { + $username = trim( $the_subscription->billing_first_name . ' ' . $the_subscription->billing_last_name ); + } + // translators: $1: is opening link, $2: is subscription order number, $3: is closing link tag, $4: is user's name + $column_content = sprintf( _x( '%1$s#%2$s%3$s for %4$s', 'Subscription title on admin table. (e.g.: #211 for John Doe)', 'woocommerce-subscriptions' ), '', '' . esc_attr( $the_subscription->get_order_number() ) . '', '', $username ); + + $column_content .= '
    '; + + break; + case 'order_items' : + // Display either the item name or item count with a collapsed list of items + $subscription_items = $the_subscription->get_items(); + switch ( count( $subscription_items ) ) { + case 0 : + $column_content .= '–'; + break; + case 1 : + foreach ( $the_subscription->get_items() as $item ) { + $_product = apply_filters( 'woocommerce_order_item_product', $the_subscription->get_product_from_item( $item ), $item ); + $item_meta = wcs_get_order_item_meta( $item, $_product ); + $item_meta_html = $item_meta->display( true, true ); + $item_quantity = absint( $item['qty'] ); + + $item_name = ''; + if ( wc_product_sku_enabled() && $_product && $_product->get_sku() ) { + $item_name .= $_product->get_sku() . ' - '; + } + $item_name .= $item['name']; + + $item_name = apply_filters( 'woocommerce_order_item_name', $item_name, $item ); + $item_name = esc_html( $item_name ); + if ( $item_quantity > 1 ) { + $item_name = sprintf( '%s × %s', absint( $item_quantity ), $item_name ); + } + if ( $_product ) { + $item_name = sprintf( '%s', get_edit_post_link( $_product->id ), $item_name ); + } + ob_start(); + ?> +
    + array( 'href' => array() ) ) ); ?> + + [?] + +
    + ' . esc_html( apply_filters( 'woocommerce_admin_order_item_count', sprintf( _n( '%d item', '%d items', $the_subscription->get_item_count(), 'woocommerce-subscriptions' ), $the_subscription->get_item_count() ), $the_subscription ) ) . ''; + $column_content .= ''; + + foreach ( $the_subscription->get_items() as $item ) { + $_product = apply_filters( 'woocommerce_order_item_product', $the_subscription->get_product_from_item( $item ), $item ); + $item_meta = wcs_get_order_item_meta( $item, $_product ); + $item_meta_html = $item_meta->display( true, true ); + ob_start(); + ?> + + + + + '; + break; + } + break; + + case 'recurring_total' : + $column_content .= esc_html( strip_tags( $the_subscription->get_formatted_order_total() ) ); + + // translators: placeholder is the display name of a payment gateway a subscription was paid by + $column_content .= '' . esc_html( sprintf( __( 'Via %s', 'woocommerce-subscriptions' ), $the_subscription->get_payment_method_to_display() ) ) . ''; + break; + + case 'start_date': + case 'trial_end_date': + case 'next_payment_date': + case 'last_payment_date': + case 'end_date': + if ( 0 == $the_subscription->get_time( $column, 'gmt' ) ) { + $column_content .= '-'; + } else { + $column_content .= sprintf( '', esc_attr( $column ), esc_attr( date( __( 'Y/m/d g:i:s A', 'woocommerce-subscriptions' ) , $the_subscription->get_time( $column, 'site' ) ) ), esc_html( $the_subscription->get_date_to_display( $column ) ) ); + + if ( 'next_payment_date' == $column && $the_subscription->payment_method_supports( 'gateway_scheduled_payments' ) && ! $the_subscription->is_manual() && $the_subscription->has_status( 'active' ) ) { + $column_content .= '
    '; + } + } + + $column_content = $column_content; + break; + + case 'orders' : + $column_content .= $this->get_related_orders_link( $the_subscription ); + break; + } + + echo wp_kses( apply_filters( 'woocommerce_subscription_list_table_column_content', $column_content, $the_subscription, $column ), array( 'a' => array( 'class' => array(), 'href' => array(), 'data-tip' => array(), 'title' => array() ), 'time' => array( 'class' => array(), 'title' => array() ), 'mark' => array( 'class' => array(), 'data-tip' => array() ), 'small' => array( 'class' => array() ), 'table' => array( 'class' => array(), 'cellspacing' => array(), 'cellpadding' => array() ), 'tr' => array( 'class' => array() ), 'td' => array( 'class' => array() ), 'div' => array( 'class' => array(), 'data-tip' => array() ), 'br' => array(), 'strong' => array(), 'span' => array( 'class' => array() ), 'p' => array( 'class' => array() ) ) ); + } + + /** + * Make columns sortable + * + * @param array $columns + * @return array + */ + public function shop_subscription_sortable_columns( $columns ) { + + $sortable_columns = array( + 'status' => 'post_status', + 'order_title' => 'ID', + 'recurring_total' => 'order_total', + 'start_date' => 'date', + 'trial_end_date' => 'trial_end_date', + 'next_payment_date' => 'next_payment_date', + 'last_payment_date' => 'last_payment_date', + 'end_date' => 'end_date', + ); + + return wp_parse_args( $sortable_columns, $columns ); + } + + /** + * Search custom fields as well as content. + * + * @access public + * @param WP_Query $wp + * @return void + */ + public function shop_subscription_search_custom_fields( $wp ) { + global $pagenow, $wpdb; + + if ( 'edit.php' !== $pagenow || empty( $wp->query_vars['s'] ) || 'shop_subscription' !== $wp->query_vars['post_type'] ) { + return; + } + + $search_fields = array_map( 'wc_clean', apply_filters( 'woocommerce_shop_subscription_search_fields', array( + '_order_key', + '_billing_company', + '_billing_address_1', + '_billing_address_2', + '_billing_city', + '_billing_postcode', + '_billing_country', + '_billing_state', + '_billing_email', + '_billing_phone', + '_shipping_address_1', + '_shipping_address_2', + '_shipping_city', + '_shipping_postcode', + '_shipping_country', + '_shipping_state', + ) ) ); + + $search_order_id = str_replace( 'Order #', '', $_GET['s'] ); + if ( ! is_numeric( $search_order_id ) ) { + $search_order_id = 0; + } + + // Search orders + $post_ids = array_unique( array_merge( + $wpdb->get_col( + $wpdb->prepare( " + SELECT p1.post_id + FROM {$wpdb->postmeta} p1 + INNER JOIN {$wpdb->postmeta} p2 ON p1.post_id = p2.post_id + WHERE + ( p1.meta_key = '_billing_first_name' AND p2.meta_key = '_billing_last_name' AND CONCAT(p1.meta_value, ' ', p2.meta_value) LIKE '%%%s%%' ) + OR + ( p1.meta_key = '_shipping_first_name' AND p2.meta_key = '_shipping_last_name' AND CONCAT(p1.meta_value, ' ', p2.meta_value) LIKE '%%%s%%' ) + OR + ( p1.meta_key IN ('" . implode( "','", esc_sql( $search_fields ) ) . "') AND p1.meta_value LIKE '%%%s%%' ) + ", + esc_attr( $_GET['s'] ), esc_attr( $_GET['s'] ), esc_attr( $_GET['s'] ) + ) + ), + $wpdb->get_col( + $wpdb->prepare( " + SELECT order_id + FROM {$wpdb->prefix}woocommerce_order_items as order_items + WHERE order_item_name LIKE '%%%s%%' + ", + esc_attr( $_GET['s'] ) + ) + ), + $wpdb->get_col( + $wpdb->prepare( " + SELECT p1.ID + FROM {$wpdb->posts} p1 + INNER JOIN {$wpdb->postmeta} p2 ON p1.ID = p2.post_id + INNER JOIN {$wpdb->users} u ON p2.meta_value = u.ID + WHERE u.user_email LIKE '%%%s%%' + AND p2.meta_key = '_customer_user' + AND p1.post_type = 'shop_subscription' + ", + esc_attr( $_GET['s'] ) + ) + ), + array( $search_order_id ) + ) ); + + // Remove s - we don't want to search order name + unset( $wp->query_vars['s'] ); + + // so we know we're doing this + $wp->query_vars['shop_subscription_search'] = true; + + // Search by found posts + $wp->query_vars['post__in'] = $post_ids; + } + + /** + * Change the label when searching orders. + * + * @access public + * @param mixed $query + * @return string + */ + public function shop_subscription_search_label( $query ) { + global $pagenow, $typenow; + + if ( 'edit.php' !== $pagenow ) { + return $query; + } + + if ( 'shop_subscription' !== $typenow ) { + return $query; + } + + if ( ! get_query_var( 'shop_subscription_search' ) ) { + return $query; + } + + return wp_unslash( $_GET['s'] ); + } + + /** + * Query vars for custom searches. + * + * @access public + * @param mixed $public_query_vars + * @return array + */ + public function add_custom_query_var( $public_query_vars ) { + $public_query_vars[] = 'sku'; + $public_query_vars[] = 'shop_subscription_search'; + + return $public_query_vars; + } + + /** + * Filters and sorting handler + * + * @param array $vars + * @return array + */ + public function request_query( $vars ) { + global $typenow; + + if ( 'shop_subscription' === $typenow ) { + + // Filter the orders by the posted customer. + if ( isset( $_GET['_customer_user'] ) && $_GET['_customer_user'] > 0 ) { + $vars['meta_query'][] = array( + 'key' => '_customer_user', + 'value' => (int) $_GET['_customer_user'], + 'compare' => '=', + ); + } + + if ( isset( $_GET['_wcs_product'] ) && $_GET['_wcs_product'] > 0 ) { + + $subscription_ids = wcs_get_subscriptions_for_product( $_GET['_wcs_product'] ); + + if ( ! empty( $subscription_ids ) ) { + $vars['post__in'] = $subscription_ids; + } else { + // no subscriptions contain this product, but we need to pass post__in an ID that no post will have because WP returns all posts when post__in is an empty array: https://core.trac.wordpress.org/ticket/28099 + $vars['post__in'] = array( 0 ); + } + } + + if ( ! empty( $_GET['_payment_method'] ) ) { + + $payment_gateway_filter = ( 'none' == $_GET['_payment_method'] ) ? '' : $_GET['_payment_method']; + + $query_vars = array( + 'post_type' => 'shop_subscription', + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => '_payment_method', + 'value' => $payment_gateway_filter, + ), + ), + ); + + // If there are already set post restrictions (post__in) apply them to this query + if ( isset( $vars['post__in'] ) ) { + $query_vars['post__in'] = $vars['post__in']; + } + + $subscription_ids = get_posts( $query_vars ); + + if ( ! empty( $subscription_ids ) ) { + $vars['post__in'] = $subscription_ids; + } else { + $vars['post__in'] = array( 0 ); + } + } + + // Sorting + if ( isset( $vars['orderby'] ) ) { + switch ( $vars['orderby'] ) { + case 'order_total' : + $vars = array_merge( $vars, array( + 'meta_key' => '_order_total', + 'orderby' => 'meta_value_num', + ) ); + break; + case 'last_payment_date' : + add_filter( 'posts_clauses', array( $this, 'posts_clauses' ), 10, 2 ); + break; + case 'trial_end_date' : + case 'next_payment_date' : + case 'end_date' : + $vars = array_merge( $vars, array( + 'meta_key' => sprintf( '_schedule_%s', str_replace( '_date', '', $vars['orderby'] ) ), + 'meta_type' => 'DATETIME', + 'orderby' => 'meta_value', + ) ); + break; + } + } + + // Status + if ( ! isset( $vars['post_status'] ) ) { + $vars['post_status'] = array_keys( wcs_get_subscription_statuses() ); + } + } + + return $vars; + } + + /** + * Change messages when a post type is updated. + * + * @param array $messages + * @return array + */ + public function post_updated_messages( $messages ) { + global $post, $post_ID; + + $messages['shop_subscription'] = array( + 0 => '', // Unused. Messages start at index 1. + 1 => __( 'Subscription updated.', 'woocommerce-subscriptions' ), + 2 => __( 'Custom field updated.', 'woocommerce-subscriptions' ), + 3 => __( 'Custom field deleted.', 'woocommerce-subscriptions' ), + 4 => __( 'Subscription updated.', 'woocommerce-subscriptions' ), + // translators: placeholder is previous post title + 5 => isset( $_GET['revision'] ) ? sprintf( _x( 'Subscription restored to revision from %s', 'used in post updated messages', 'woocommerce-subscriptions' ), wp_post_revision_title( (int) $_GET['revision'], false ) ) : false, + 6 => __( 'Subscription updated.', 'woocommerce-subscriptions' ), + 7 => __( 'Subscription saved.', 'woocommerce-subscriptions' ), + 8 => __( 'Subscription submitted.', 'woocommerce-subscriptions' ), + // translators: php date string + 9 => sprintf( __( 'Subscription scheduled for: %1$s.', 'woocommerce-subscriptions' ), '' . date_i18n( _x( 'M j, Y @ G:i', 'used in "Subscription scheduled for "', 'woocommerce-subscriptions' ), strtotime( $post->post_date ) ) . '' ), + 10 => __( 'Subscription draft updated.', 'woocommerce-subscriptions' ), + ); + + return $messages; + } + + /** + * Returns a clickable link that takes you to a collection of orders relating to the subscription. + * + * @uses self::get_related_orders() + * @since 2.0 + * @return string the link string + */ + public function get_related_orders_link( $the_subscription ) { + $order_id = isset( $the_subscription->order->id ) ? $the_subscription->order->id : 0; + + return sprintf( + '%s', + admin_url( 'edit.php?post_status=all&post_type=shop_order&_subscription_related_orders=' . absint( $the_subscription->id ) ), + count( $the_subscription->get_related_orders() ) + ); + } + + /** + * Displays the dropdown for the payment method filter. + * + * @since 2.0 + */ + public static function restrict_by_payment_method() { + global $typenow; + + if ( 'shop_subscription' !== $typenow ) { + return; + } + + $selected_gateway_id = ( ! empty( $_GET['_payment_method'] ) ) ? $_GET['_payment_method'] : ''; ?> + + ID ) ) { + $subscription = wcs_get_subscription( $post->ID ); + $order = ( false == $subscription->order ) ? $subscription : $subscription->order; + } else { + $order = wc_get_order( $post->ID ); + } + + add_action( 'woocommerce_subscriptions_related_orders_meta_box_rows', __CLASS__ . '::output_rows', 10 ); + + include_once( 'views/html-related-orders-table.php' ); + + do_action( 'woocommerce_subscriptions_related_orders_meta_box', $order, $post ); + } + + /** + * Displays the renewal orders in the Related Orders meta box. + * + * @param object $post A WordPress post + * @since 2.0 + */ + public static function output_rows( $post ) { + + $subscriptions = array(); + $orders = array(); + + // On the subscription page, just show related orders + if ( wcs_is_subscription( $post->ID ) ) { + $subscriptions[] = wcs_get_subscription( $post->ID ); + } elseif ( wcs_order_contains_subscription( $post->ID, array( 'parent', 'renewal' ) ) ) { + $subscriptions = wcs_get_subscriptions_for_order( $post->ID, array( 'order_type' => array( 'parent', 'renewal' ) ) ); + } + + // First, display all the subscriptions + foreach ( $subscriptions as $subscription ) { + $subscription->relationship = __( 'Subscription', 'woocommerce-subscriptions' ); + $orders[] = $subscription; + } + + //Resubscribed + $initial_subscriptions = array(); + + if ( wcs_is_subscription( $post->ID ) ) { + + $initial_subscriptions = wcs_get_subscriptions_for_resubscribe_order( $post->ID ); + + $resubscribed_subscriptions = get_posts( array( + 'meta_key' => '_subscription_resubscribe', + 'meta_value' => $post->ID, + 'post_type' => 'shop_subscription', + 'post_status' => 'any', + 'posts_per_page' => -1, + ) ); + + foreach ( $resubscribed_subscriptions as $subscription ) { + $subscription = wcs_get_subscription( $subscription ); + $subscription->relationship = _x( 'Resubscribed Subscription', 'relation to order', 'woocommerce-subscriptions' ); + $orders[] = $subscription; + } + } else if ( wcs_order_contains_subscription( $post->ID, array( 'resubscribe' ) ) ) { + $initial_subscriptions = wcs_get_subscriptions_for_order( $post->ID, array( 'order_type' => array( 'resubscribe' ) ) ); + } + + foreach ( $initial_subscriptions as $subscription ) { + $subscription->relationship = _x( 'Initial Subscription', 'relation to order', 'woocommerce-subscriptions' ); + $orders[] = $subscription; + } + + // Now, if we're on a single subscription or renewal order's page, display the parent orders + if ( 1 == count( $subscriptions ) ) { + foreach ( $subscriptions as $subscription ) { + if ( false !== $subscription->order ) { + $subscription->order->relationship = _x( 'Parent Order', 'relation to order', 'woocommerce-subscriptions' ); + $orders[] = $subscription->order; + } + } + } + + // Finally, display the renewal orders + foreach ( $subscriptions as $subscription ) { + + foreach ( $subscription->get_related_orders( 'all', 'renewal' ) as $order ) { + $order->relationship = _x( 'Renewal Order', 'relation to order', 'woocommerce-subscriptions' ); + $orders[] = $order; + } + } + + foreach ( $orders as $order ) { + if ( $order->id == $post->ID ) { + continue; + } + include( 'views/html-related-orders-row.php' ); + } + } +} diff --git a/includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php b/includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php new file mode 100644 index 0000000..69c2502 --- /dev/null +++ b/includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php @@ -0,0 +1,281 @@ +id !== $post->ID ) { + $the_subscription = wc_get_order( $post->ID ); + } + + $subscription = $the_subscription; + + self::init_address_fields(); + + wp_nonce_field( 'woocommerce_save_data', 'woocommerce_meta_nonce' ); + ?> + +
    + + +
    + +

    get_order_number() ) ); ?>

    + +
    +
    + +

    + + customer_user ) && ( false !== get_userdata( $subscription->customer_user ) ) ) { + $user_id = absint( $subscription->customer_user ); + $user = get_user_by( 'id', $user_id ); + $user_string = esc_html( $user->display_name ) . ' (#' . absint( $user->ID ) . ' – ' . esc_html( $user->user_email ); + } + ?> + +

    + +

    + + +

    + + + +
    +
    +

    + '; + + if ( $subscription->get_formatted_billing_address() ) { + echo '

    ' . esc_html__( 'Address', 'woocommerce-subscriptions' ) . ':' . wp_kses( $subscription->get_formatted_billing_address(), array( 'br' => array() ) ) . '

    '; + } else { + echo '

    ' . esc_html__( 'Address', 'woocommerce-subscriptions' ) . ': ' . esc_html__( 'No billing address set.', 'woocommerce-subscriptions' ) . '

    '; + } + + foreach ( self::$billing_fields as $key => $field ) { + + if ( isset( $field['show'] ) && false === $field['show'] ) { + continue; + } + + $field_name = 'billing_' . $key; + + if ( $subscription->$field_name ) { + echo '

    ' . esc_html( $field['label'] ) . ': ' . wp_kses_post( make_clickable( esc_html( $subscription->$field_name ) ) ) . '

    '; + } + } + + echo 'payment_method ) ? ' class="' . esc_attr( $subscription->payment_method ) . '"' : '' ) . '>' . esc_html__( 'Payment Method', 'woocommerce-subscriptions' ) . ':' . wp_kses_post( nl2br( $subscription->get_payment_method_to_display() ) ); + + // Display help tip + if ( ! empty( $subscription->payment_method ) && ! $subscription->is_manual() ) { + echo ''; + } + + echo '

    '; + + echo '
    '; + + // Display form + echo '
    '; + + foreach ( self::$billing_fields as $key => $field ) { + if ( ! isset( $field['type'] ) ) { + $field['type'] = 'text'; + } + + switch ( $field['type'] ) { + case 'select' : + // allow for setting a default value programaticaly, and draw the selectbox + woocommerce_wp_select( array( 'id' => '_billing_' . $key, 'label' => $field['label'], 'options' => $field['options'], 'value' => isset( $field['value'] ) ? $field['value'] : null ) ); + break; + default : + // allow for setting a default value programaticaly, and draw the textbox + woocommerce_wp_text_input( array( 'id' => '_billing_' . $key, 'label' => $field['label'], 'value' => isset( $field['value'] ) ? $field['value'] : null ) ); + break; + } + } + WCS_Change_Payment_Method_Admin::display_fields( $subscription ); + + echo '
    '; + + do_action( 'woocommerce_admin_order_data_after_billing_address', $subscription ); + ?> +
    +
    + +

    + + + + +

    + '; + + if ( $subscription->get_formatted_shipping_address() ) { + echo '

    ' . esc_html__( 'Address', 'woocommerce-subscriptions' ) . ':' . wp_kses( $subscription->get_formatted_shipping_address(), array( 'br' => array() ) ) . '

    '; + } else { + echo '

    ' . esc_html__( 'Address', 'woocommerce-subscriptions' ) . ': ' . esc_html__( 'No shipping address set.', 'woocommerce-subscriptions' ) . '

    '; + } + + if ( self::$shipping_fields ) { + foreach ( self::$shipping_fields as $key => $field ) { + if ( isset( $field['show'] ) && false === $field['show'] ) { + continue; + } + + $field_name = 'shipping_' . $key; + + if ( ! empty( $subscription->$field_name ) ) { + echo '

    ' . esc_html( $field['label'] ) . ': ' . wp_kses_post( make_clickable( esc_html( $subscription->$field_name ) ) ) . '

    '; + } + } + } + + if ( apply_filters( 'woocommerce_enable_order_notes_field', 'yes' == get_option( 'woocommerce_enable_order_comments', 'yes' ) ) && $post->post_excerpt ) { + echo '

    ' . esc_html__( 'Customer Note:', 'woocommerce-subscriptions' ) . ' ' . wp_kses_post( nl2br( $post->post_excerpt ) ) . '

    '; + } + + echo '
    '; + + // Display form + echo '
    '; + + if ( self::$shipping_fields ) { + foreach ( self::$shipping_fields as $key => $field ) { + if ( ! isset( $field['type'] ) ) { + $field['type'] = 'text'; + } + + switch ( $field['type'] ) { + case 'select' : + woocommerce_wp_select( array( 'id' => '_shipping_' . $key, 'label' => $field['label'], 'options' => $field['options'] ) ); + break; + default : + woocommerce_wp_text_input( array( 'id' => '_shipping_' . $key, 'label' => $field['label'] ) ); + break; + } + } + } + + if ( apply_filters( 'woocommerce_enable_order_notes_field', 'yes' == get_option( 'woocommerce_enable_order_comments', 'yes' ) ) ) { + ?> +

    +

    + '; + + do_action( 'woocommerce_admin_order_data_after_shipping_address', $subscription ); + ?> +
    +
    +
    +
    + + post_type || empty( $_POST['woocommerce_meta_nonce'] ) || ! wp_verify_nonce( $_POST['woocommerce_meta_nonce'], 'woocommerce_save_data' ) ) { + return; + } + + self::init_address_fields(); + + // Update meta + update_post_meta( $post_id, '_customer_user', absint( $_POST['customer_user'] ) ); + + if ( self::$billing_fields ) { + foreach ( self::$billing_fields as $key => $field ) { + update_post_meta( $post_id, '_billing_' . $key, wc_clean( $_POST[ '_billing_' . $key ] ) ); + } + } + + if ( self::$shipping_fields ) { + foreach ( self::$shipping_fields as $key => $field ) { + update_post_meta( $post_id, '_shipping_' . $key, wc_clean( $_POST[ '_shipping_' . $key ] ) ); + } + } + + $subscription = wcs_get_subscription( $post_id ); + + try { + WCS_Change_Payment_Method_Admin::save_meta( $subscription ); + + if ( 'cancelled' == $_POST['order_status'] ) { + $subscription->cancel_order(); + } else { + $subscription->update_status( $_POST['order_status'], '', true ); + } + } catch ( Exception $e ) { + // translators: placeholder is error message from the payment gateway or subscriptions when updating the status + wcs_add_admin_notice( sprintf( __( 'Error updating subscription: %s', 'woocommerce-subscriptions' ), $e->getMessage() ), 'error' ); + } + + do_action( 'woocommerce_process_shop_subscription_meta', $post_id, $post ); + } + +} diff --git a/includes/admin/meta-boxes/class-wcs-meta-box-subscription-schedule.php b/includes/admin/meta-boxes/class-wcs-meta-box-subscription-schedule.php new file mode 100644 index 0000000..57c4e34 --- /dev/null +++ b/includes/admin/meta-boxes/class-wcs-meta-box-subscription-schedule.php @@ -0,0 +1,81 @@ +ID ); + } + + include( 'views/html-subscription-schedule.php' ); + } + + /** + * Save meta box data + */ + public static function save( $post_id, $post ) { + + if ( 'shop_subscription' == $post->post_type && ! empty( $_POST['woocommerce_meta_nonce'] ) && wp_verify_nonce( $_POST['woocommerce_meta_nonce'], 'woocommerce_save_data' ) ) { + + if ( isset( $_POST['_billing_interval'] ) ) { + update_post_meta( $post_id, '_billing_interval', $_POST['_billing_interval'] ); + } + + if ( ! empty( $_POST['_billing_period'] ) ) { + update_post_meta( $post_id, '_billing_period', $_POST['_billing_period'] ); + } + + $subscription = wcs_get_subscription( $post_id ); + + $dates = array(); + + foreach ( wcs_get_subscription_date_types() as $date_key => $date_label ) { + + if ( 'last_payment' == $date_key ) { + continue; + } + + $utc_timestamp_key = $date_key . '_timestamp_utc'; + + // A subscription needs a start date, even if it wasn't set + if ( isset( $_POST[ $utc_timestamp_key ] ) ) { + $datetime = $_POST[ $utc_timestamp_key ]; + } elseif ( 'start' === $date_key ) { + $datetime = current_time( 'timestamp', true ); + } else { // No date to set + continue; + } + + $dates[ $date_key ] = date( 'Y-m-d H:i:s', $datetime ); + } + + try { + $subscription->update_dates( $dates, 'gmt' ); + + wp_cache_delete( $post_id, 'posts' ); + } catch ( Exception $e ) { + wcs_add_admin_notice( $e->getMessage(), 'error' ); + } + } + } +} diff --git a/includes/admin/meta-boxes/views/html-related-orders-row.php b/includes/admin/meta-boxes/views/html-related-orders-row.php new file mode 100644 index 0000000..af255bd --- /dev/null +++ b/includes/admin/meta-boxes/views/html-related-orders-row.php @@ -0,0 +1,55 @@ + + + + + + + + diff --git a/includes/admin/meta-boxes/views/html-related-orders-table.php b/includes/admin/meta-boxes/views/html-related-orders-table.php new file mode 100644 index 0000000..8038f3c --- /dev/null +++ b/includes/admin/meta-boxes/views/html-related-orders-table.php @@ -0,0 +1,28 @@ + +
    + get_sku() ) { + echo esc_html( $_product->get_sku() ) . ' - '; + } + echo esc_html( apply_filters( 'woocommerce_order_item_name', $item['name'], $item ) ); + if ( $item_meta_html ) { ?> + [?] + +
    + + get_order_number() ) ); ?> + + + relationship ); ?> + + post->post_date_gmt ); + + if ( $timestamp_gmt > 0 ) { + + // translators: php date format + $t_time = get_the_time( _x( 'Y/m/d g:i:s A', 'post date', 'woocommerce-subscriptions' ), $order->post ); + $time_diff = $timestamp_gmt - current_time( 'timestamp', true ); + + if ( $time_diff > 0 && $time_diff < WEEK_IN_SECONDS ) { + // translators: placeholder is human time diff (e.g. "3 weeks") + $date_to_display = sprintf( __( 'In %s', 'woocommerce-subscriptions' ), human_time_diff( current_time( 'timestamp', true ), $timestamp_gmt ) ); + } elseif ( $time_diff < 0 && absint( $time_diff ) < WEEK_IN_SECONDS ) { + // translators: placeholder is human time diff (e.g. "3 weeks") + $date_to_display = sprintf( __( '%s ago', 'woocommerce-subscriptions' ), human_time_diff( current_time( 'timestamp', true ), $timestamp_gmt ) ); + } else { + $timestamp_site = strtotime( get_date_from_gmt( date( 'Y-m-d H:i:s', $timestamp_gmt ) ) ); + $date_to_display = date_i18n( wc_date_format(), $timestamp_site ) . ' ' . date_i18n( wc_time_format(), $timestamp_site ); + } + } else { + $t_time = $date_to_display = __( 'Unpublished', 'woocommerce-subscriptions' ); + } ?> + + post ) ); ?> + + + get_status() ) ); ?> + + get_formatted_order_total(), array( 'small' => array(), 'span' => array( 'class' => array() ), 'del' => array(), 'ins' => array() ) ); ?> +
    + + + + + + + + + + + + +
    +
    diff --git a/includes/admin/meta-boxes/views/html-subscription-schedule.php b/includes/admin/meta-boxes/views/html-subscription-schedule.php new file mode 100644 index 0000000..37e3d65 --- /dev/null +++ b/includes/admin/meta-boxes/views/html-subscription-schedule.php @@ -0,0 +1,61 @@ + +
    + +
    + can_date_be_updated( 'next_payment' ) ) : ?> +
    '_billing_interval', + 'class' => 'billing_interval', + 'label' => __( 'Recurring:', 'woocommerce-subscriptions' ), + 'value' => empty( $the_subscription->billing_interval ) ? 1 : $the_subscription->billing_interval, + 'options' => wcs_get_subscription_period_interval_strings(), + ) + ); + + // Billing Period + echo woocommerce_wp_select( array( + 'id' => '_billing_period', + 'class' => 'billing_period', + 'label' => __( 'Billing Period', 'woocommerce-subscriptions' ), + 'value' => empty( $the_subscription->billing_period ) ? 'month' : $the_subscription->billing_period, + 'options' => wcs_get_subscription_period_strings(), + ) + ); + ?> + +
    + + + billing_interval ) ), esc_html( wcs_get_subscription_period_strings( 1, $the_subscription->billing_period ) ) ); ?> + +
    + + $date_label ) : ?> + + + +
    + : + + can_date_be_updated( $date_key ) ) : ?> + get_time( $date_key, 'site' ), array( 'name_attr' => $date_key ) ), array( 'input' => array( 'type' => array(), 'class' => array(), 'placeholder' => array(), 'name' => array(), 'id' => array(), 'maxlength' => array(), 'size' => array(), 'value' => array(), 'patten' => array() ), 'div' => array( 'class' => array() ), 'span' => array(), 'br' => array() ) ); ?> + + get_date_to_display( $date_key ) ); ?> + +
    + +

    +
    diff --git a/includes/admin/wcs-admin-functions.php b/includes/admin/wcs-admin-functions.php new file mode 100644 index 0000000..c404b7c --- /dev/null +++ b/includes/admin/wcs-admin-functions.php @@ -0,0 +1,71 @@ +

    ' . wp_kses_post( implode( "

    \n

    ", $notices['success'] ) ) . '

    '; + } + + if ( ! empty( $notices['error'] ) ) { + array_walk( $notices['error'], 'esc_html' ); + echo '

    ' . wp_kses_post( implode( "

    \n

    ", $notices['error'] ) ) . '

    '; + } + } + + if ( false !== $clear ) { + wcs_clear_admin_notices(); + } +} +add_action( 'admin_notices', 'wcs_display_admin_notices' ); + +/** + * Delete any admin notices we stored for display later. + * + * @since 2.0 + */ +function wcs_clear_admin_notices() { + delete_transient( '_wcs_admin_notices' ); +} diff --git a/includes/api/class-wc-api-subscriptions-customers.php b/includes/api/class-wc-api-subscriptions-customers.php new file mode 100644 index 0000000..9442b5f --- /dev/null +++ b/includes/api/class-wc-api-subscriptions-customers.php @@ -0,0 +1,91 @@ +/subscriptions + * + * @since 2.0 + * @param array $routes + * @return array + */ + public function register_routes( $routes ) { + $routes = parent::register_routes( $routes ); + + # GET /customers//subscriptions + $routes[ $this->base . '/(?P\d+)/subscriptions' ] = array( + array( array( $this, 'get_customer_subscriptions' ), WC_API_SERVER::READABLE ), + ); + + return $routes; + } + + /** + * WCS API function to get all the subscriptions tied to a particular customer. + * + * @since 2.0 + * @param $id int + * @param $fields array + */ + public function get_customer_subscriptions( $id, $fields = null ) { + global $wpdb; + + // check the customer id given is a valid customer in the store. We're able to leech off WC-API for this. + $id = $this->validate_request( $id, 'customer', 'read' ); + + if ( is_wp_error( $id ) ) { + return $id; + } + + $subscription_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID, post_date_gmt + FROM {$wpdb->posts} AS posts + LEFT JOIN {$wpdb->postmeta} AS meta on posts.ID = meta.post_id + WHERE meta.meta_key = '_customer_user' + AND meta.meta_value = '%d' + AND posts.post_type = 'shop_subscription' + AND posts.post_status IN ( '" . implode( "','", array_keys( wcs_get_subscription_statuses() ) ) . "' ) + GROUP BY posts.ID + ORDER BY posts.post_date_gmt DESC + ", $id ) ); + + $subscriptions = array(); + + foreach ( $subscription_ids as $subscription_id ) { + $subscriptions[] = WC()->api->WC_API_Subscriptions->get_subscription( $subscription_id, $fields ); + } + + return array( 'customer_subscriptions' => apply_filters( 'wc_subscriptions_api_customer_subscriptions', $subscriptions, $id, $fields, $subscription_ids, $this->server ) ); + } +} diff --git a/includes/api/class-wc-api-subscriptions.php b/includes/api/class-wc-api-subscriptions.php new file mode 100644 index 0000000..148cc40 --- /dev/null +++ b/includes/api/class-wc-api-subscriptions.php @@ -0,0 +1,702 @@ + + * GET /subscriptions//notes + * GET /subscriptions//notes/ + * GET /subscriptions//orders + * + * @since 2.0 + * @param array $routes + * @return array $routes + */ + public function register_routes( $routes ) { + + $this->post_type = 'shop_subscription'; + + # GET /subscriptions + $routes[ $this->base ] = array( + array( array( $this, 'get_subscriptions' ), WC_API_Server::READABLE ), + array( array( $this, 'create_subscription' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), + ); + + # GET /subscriptions/count + $routes[ $this->base . '/count' ] = array( + array( array( $this, 'get_subscription_count' ), WC_API_Server::READABLE ), + ); + + # GET /subscriptions/statuses + $routes[ $this->base . '/statuses' ] = array( + array( array( $this, 'get_statuses' ), WC_API_Server::READABLE ), + ); + + # GET|PUT|DELETE /subscriptions/ + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'get_subscription' ), WC_API_Server::READABLE ), + array( array( $this, 'edit_subscription' ), WC_API_Server::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'delete_subscription' ), WC_API_Server::DELETABLE ), + ); + + # GET /subscriptions//notes + $routes[ $this->base . '/(?P\d+)/notes' ] = array( + array( array( $this, 'get_subscription_notes' ), WC_API_Server::READABLE ), + array( array( $this, 'create_subscription_note' ), WC_API_Server::CREATABLE | WC_API_Server::ACCEPT_DATA ), + ); + + # GET /subscriptions//notes/ + $routes[ $this->base . '/(?P\d+)/notes/(?P\d+)' ] = array( + array( array( $this, 'get_subscription_note' ), WC_API_Server::READABLE ), + array( array( $this, 'edit_subscription_note' ), WC_API_SERVER::EDITABLE | WC_API_Server::ACCEPT_DATA ), + array( array( $this, 'delete_subscription_note' ), WC_API_SERVER::DELETABLE ), + ); + + # GET /subscriptions//orders + $routes[ $this->base . '/(?P\d+)/orders' ] = array( + array( array( $this, 'get_all_subscription_orders' ), WC_API_Server::READABLE ), + ); + + return $routes; + } + + /** + * Ensures the statuses are in the correct format and are valid subscription statues. + * + * @since 2.0 + * @param $status string | array + */ + protected function format_statuses( $status = null ) { + $statuses = 'any'; + + if ( ! empty( $status ) ) { + // get list of statuses and check each on is in the correct format and is valid + $statuses = explode( ',', $status ); + + // attach the wc- prefix to those statuses that have not specified it + foreach ( $statuses as &$status ) { + if ( 'wc-' != substr( $status, 0, 3 ) ) { + $status = 'wc-' . $status; + + if ( ! array_key_exists( $status, wcs_get_subscription_statuses() ) ) { + return new WP_Error( 'wcs_api_invalid_subscription_status', __( 'Invalid subscription status given.', 'woocommerce-subscriptions' ) ); + } + } + } + } + + return $statuses; + } + + /** + * Gets all subscriptions + * + * @since 2.0 + * @param null $fields + * @param array $filter + * @param null $status + * @param null $page + * @return array + */ + public function get_subscriptions( $fields = null, $filter = array(), $status = null, $page = 1 ) { + // check user permissions + if ( ! current_user_can( 'read_private_shop_orders' ) ) { + return new WP_Error( 'wcs_api_user_cannot_read_susbcription_count', __( 'You do not have permission to read the subscriptions count', 'woocommerce-subscriptions' ), array( 'status' => 401 ) ); + } + + $status = $this->format_statuses( $status ); + + if ( is_wp_error( $status ) ) { + return $status; + } + + $filter['page'] = $page; + + $base_args = array( + 'post_status' => $status, + 'post_type' => 'shop_subscription', + 'fields' => 'ids', + ); + + $subscriptions = array(); + $query_args = array_merge( $base_args, $filter ); + $query = $this->query_orders( $query_args ); + + foreach ( $query->posts as $subscription_id ) { + + if ( ! $this->is_readable( $subscription_id ) ) { + continue; + } + + $subscriptions[] = current( $this->get_subscription( $subscription_id, $fields, $filter ) ); + } + + $this->server->add_pagination_headers( $query ); + + return array( 'subscriptions' => apply_filters( 'wcs_api_get_subscriptions_response', $subscriptions, $fields, $filter, $status, $page, $this->server ) ); + } + + /** + * Creating Subscription. + * + * @since 2.0 + * @param array data raw order data + * @return array + */ + public function create_subscription( $data ) { + + $data = isset( $data['subscription'] ) ? $data['subscription'] : array(); + + try { + + if ( ! current_user_can( 'publish_shop_orders' ) ) { + throw new WC_API_Exception( 'wcs_api_user_cannot_create_subscription', __( 'You do not have permission to create subscriptions', 'woocommerce-subscriptions' ), 401 ); + } + + $data['order'] = $data; + + remove_filter( 'woocommerce_api_order_response', array( WC()->api->WC_API_Customers, 'add_customer_data' ), 10 ); + + $subscription = $this->create_order( $data ); + + add_filter( 'woocommerce_api_order_response', array( WC()->api->WC_API_Customers, 'add_customer_data' ), 10, 2 ); + + unset( $data['order'] ); + + if ( is_wp_error( $subscription ) ) { + $data = $subscription->get_error_data(); + throw new WC_API_Exception( $subscription->get_error_code(), $subscription->get_error_message(), $data['status'] ); + } + + $subscription = wcs_get_subscription( $subscription['order']['id'] ); + unset( $data['billing_period'] ); + unset( $data['billing_interval'] ); + + $this->update_schedule( $subscription, $data ); + + // allow order total to be manually set, especially for those cases where there's no line items added to the subscription + if ( isset( $data['order_total'] ) ) { + update_post_meta( $subscription->id, '_order_total', wc_format_decimal( $data['order_total'], get_option( 'woocommerce_price_num_decimals' ) ) ); + } + + if ( isset( $data['payment_details'] ) && is_array( $data['payment_details'] ) ) { + $this->update_payment_method( $subscription, $data['payment_details'], false ); + + } + + do_action( 'wcs_api_subscription_created', $subscription->id, $this ); + + return array( 'creating_subscription' => $this->get_subscription( $subscription->id ) ); + + } catch ( WC_API_Exception $e ) { + return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); + } catch ( Exception $e ) { + $response = array( 'error', array( 'wcs_api_error_create_subscription' => array( 'message' => $e->getMessage(), 'status' => $e->getCode() ) ) ); + + // show the subscription in response if it was still created but errored. + if ( ! empty( $subscription ) && ! is_wp_error( $subscription ) ) { + $response['creating_subscription'] = $this->get_subscription( $subscription->id ); + } + + return $response; + } + } + + /** + * Edit Subscription + * + * @since 2.0 + * @return array + */ + public function edit_subscription( $subscription_id, $data, $fields = null ) { + $data = apply_filters( 'wcs_api_edit_subscription_data', isset( $data['subscription'] ) ? $data['subscription'] : array(), $subscription_id, $fields ); + + try { + + $subscription_id = $this->validate_request( $subscription_id, $this->post_type, 'edit' ); + + if ( is_wp_error( $subscription_id ) ) { + throw new WC_API_Exception( 'wcs_api_cannot_edit_subscription', __( 'The requested subscription cannot be edited.', 'woocommerce-subscriptions' ), 400 ); + } + + $subscription = wcs_get_subscription( $subscription_id ); + + if ( isset( $data['payment_details'] ) && is_array( $data['payment_details'] ) ) { + + if ( empty( $data['payment_details']['method_id'] ) || 'manual' == $data['payment_details']['method_id'] ) { + $subscription->set_payment_method( '' ); + } else { + $this->update_payment_method( $subscription, $data['payment_details'], true ); + } + } + + if ( ! empty( $data['order_id'] ) ) { + wp_update_post( array( 'ID' => $subscription_id, 'post_parent' => $data['order_id'] ) ); + } + + // set $data['order'] = $data['subscription'] so that edit_order can read in the request + $data['order'] = $data; + // edit subscription by calling WC_API_Orders::edit_order() + $edited = $this->edit_order( $subscription_id, $data, $fields ); + // remove part of the array that isn't being used + unset( $data['order'] ); + + if ( is_wp_error( $edited ) ) { + $data = $edited->get_error_data(); + // translators: placeholder is error message + throw new WC_API_Exception( 'wcs_api_cannot_edit_subscription', sprintf( _x( 'Edit subscription failed with error: %s', 'API error message when editing the order failed', 'woocommerce-subscriptions' ), $edited->get_error_message() ), $data['status'] ); + } + + $this->update_schedule( $subscription, $data ); + + do_action( 'wcs_api_subscription_updated', $subscription_id, $data, $this ); + + return $this->get_subscription( $subscription_id ); + + } catch ( WC_API_Excpetion $e ) { + return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); + + } catch ( Exception $e ) { + return new WP_Error( 'wcs_api_cannot_edit_subscription', $e->getMessage(), array( 'status' => $e->getCode() ) ); + + } + + } + + /** + * Setup the new payment information to call WC_Subscription::set_payment_method() + * + * @param $subscription WC_Subscription + * @param $payment_details array payment data from api request + * @since 2.0 + */ + public function update_payment_method( $subscription, $payment_details, $updating ) { + global $wpdb; + + $payment_gateways = WC()->payment_gateways->get_available_payment_gateways(); + $payment_method = ( ! empty( $payment_details['method_id'] ) ) ? $payment_details['method_id'] : 'manual'; + $payment_gateway = ( isset( $payment_gateways[ $payment_details['method_id'] ] ) ) ? $payment_gateways[ $payment_details['method_id'] ] : ''; + + try { + $wpdb->query( 'START TRANSACTION' ); + + if ( $updating && ! array_key_exists( $payment_method, WCS_Change_Payment_Method_Admin::get_valid_payment_methods( $subscription ) ) ) { + throw new Exception( 'wcs_api_edit_subscription_error', __( 'Gateway does not support admin changing the payment method on a Subscription.', 'woocommerce-subscriptions' ) ); + } + + $payment_method_meta = apply_filters( 'woocommerce_subscription_payment_meta', array(), $subscription ); + + if ( ! empty( $payment_gateway ) && isset( $payment_method_meta[ $payment_gateway->id ] ) ) { + $payment_method_meta = $payment_method_meta[ $payment_gateway->id ]; + + if ( ! empty( $payment_method_meta ) ) { + + foreach ( $payment_method_meta as $meta_table => &$meta ) { + + if ( ! is_array( $meta ) ) { + continue; + } + + foreach ( $meta as $meta_key => &$meta_data ) { + + if ( isset( $payment_details[ $meta_table ][ $meta_key ] ) ) { + $meta_data['value'] = $payment_details[ $meta_table ][ $meta_key ]; + } + } + } + } + } + + if ( empty( $subscription->payment_gateway ) ) { + $subscription->payment_gateway = $payment_gateway; + } + + $subscription->set_payment_method( $payment_gateway, $payment_method_meta ); + + $wpdb->query( 'COMMIT' ); + + } catch ( Exception $e ) { + $wpdb->query( 'ROLLBACK' ); + + // translators: 1$: gateway id, 2$: error message + throw new Exception( sprintf( __( 'Subscription payment method could not be set to %1$s and has been set to manual with error message: %2$s', 'woocommerce-subscriptions' ), ( ! empty( $payment_gateway->id ) ) ? $payment_gateway->id : 'manual', $e->getMessage() ) ); + } + } + + /** + * Override WC_API_Order::create_base_order() to create a subscription + * instead of a WC_Order when calling WC_API_Order::create_order(). + * + * @since 2.0 + * @param $array + * @return WC_Subscription + */ + protected function create_base_order( $args, $data ) { + + $args['order_id'] = ( ! empty( $data['order_id'] ) ) ? $data['order_id'] : ''; + $args['billing_interval'] = ( ! empty( $data['billing_interval'] ) ) ? $data['billing_interval'] : ''; + $args['billing_period'] = ( ! empty( $data['billing_period'] ) ) ? $data['billing_period'] : ''; + + return wcs_create_subscription( $args ); + } + + /** + * Update all subscription specific meta (i.e. Billing interval/period and date fields ) + * + * @since 2.0 + * @param $data array + * @param $subscription WC_Subscription + */ + protected function update_schedule( $subscription, $data ) { + + if ( isset( $data['billing_interval'] ) ) { + + $interval = absint( $data['billing_interval'] ); + + if ( 0 == $interval ) { + throw new WC_API_Exception( 'wcs_api_invalid_subscription_meta', __( 'Invalid subscription billing interval given. Must be an integer greater than 0.', 'woocommerce-subscriptions' ), 400 ); + } + + update_post_meta( $subscription->id, '_billing_interval', $interval ); + + } + + if ( ! empty( $data['billing_period'] ) ) { + + $period = strtolower( $data['billing_period'] ); + + if ( ! in_array( $period, array_keys( wcs_get_subscription_period_strings() ) ) ) { + throw new WC_API_Exception( 'wcs_api_invalid_subscription_meta', __( 'Invalid subscription billing period given.', 'woocommerce-subscriptions' ), 400 ); + } + + update_post_meta( $subscription->id, '_billing_period', $period ); + } + + $dates_to_update = array(); + + foreach ( array( 'start', 'trial_end', 'end', 'next_payment' ) as $date_type ) { + + if ( isset( $data[ $date_type . '_date' ] ) ) { + $dates_to_update[ $date_type ] = $data[ $date_type . '_date' ]; + } + } + + if ( ! empty( $dates_to_update ) ) { + $subscription->update_dates( $dates_to_update ); + } + + } + + /** + * Delete subscription + * + * @since 2.0 + */ + public function delete_subscription( $subscription_id, $force = false ) { + + $subscription_id = $this->validate_request( $subscription_id, $this->post_type, 'delete' ); + + if ( is_wp_error( $subscription_id ) ) { + return $subscription_id; + } + + wc_delete_shop_order_transients( $subscription_id ); + + do_action( 'woocommerce_api_delete_subscription', $subscription_id, $this ); + + return $this->delete( $subscription_id, 'subscription', ( 'true' === $force ) ); + } + + /** + * Retrieves the subscription by the given id. + * + * Called by: /subscriptions/ + * + * @since 2.0 + * @param int $subscription_id + * @param array $fields + * @param array $filter + * @return array + */ + public function get_subscription( $subscription_id, $fields = null, $filter = array() ) { + + $subscription_id = $this->validate_request( $subscription_id, $this->post_type, 'read' ); + + if ( is_wp_error( $subscription_id ) ) { + return $subscription_id; + } + + $subscription = wcs_get_subscription( $subscription_id ); + $order_data = $this->get_order( $subscription_id ); + $subscription_data = $order_data['order']; + + // Not all order meta relates to a subscription (a subscription doesn't "complete") + if ( isset( $subscription_data['completed_at'] ) ) { + unset( $subscription_data['completed_at'] ); + } + + $subscription_data['billing_schedule'] = array( + 'period' => $subscription->billing_period, + 'interval' => $subscription->billing_interval, + 'start_at' => $this->get_formatted_datetime( $subscription, 'start' ), + 'trial_end_at' => $this->get_formatted_datetime( $subscription, 'trial_end' ), + 'next_payment_at' => $this->get_formatted_datetime( $subscription, 'next_payment' ), + 'end_at' => $this->get_formatted_datetime( $subscription, 'end' ), + ); + + if ( ! empty( $subscription->order ) ) { + $subscription_data['parent_order_id'] = $subscription->order->id; + } else { + $subscription_data['parent_order_id'] = array(); + } + + return array( 'subscription' => apply_filters( 'wcs_api_get_subscription_response', $subscription_data, $fields, $filter, $this->server ) ); + } + + /** + * Returns a list of all the available subscription statuses. + * + * @see wcs_get_subscription_statuses() in wcs-functions.php + * @since 2.0 + * @return array + * + */ + public function get_statuses() { + return array( 'subscription_statuses' => wcs_get_subscription_statuses() ); + } + + /** + * Get the total number of subscriptions + * + * Called by: /subscriptions/count + * @since 2.0 + * @param $status string + * @param $filter array + * @return int | WP_Error + */ + public function get_subscription_count( $status = null, $filter = array() ) { + return $this->get_orders_count( $status, $filter ); + } + + /** + * Returns all the notes tied to the subscription + * + * Called by: subscription//notes + * @since 2.0 + * @param $subscription_id + * @param $fields + * @return WP_Error|array + */ + public function get_subscription_notes( $subscription_id, $fields = null ) { + + $notes = $this->get_order_notes( $subscription_id, $fields ); + + if ( is_wp_error( $notes ) ) { + return $notes; + } + + return array( 'subscription_notes' => apply_filters( 'wcs_api_subscription_notes_response', $notes['order_notes'], $subscription_id, $fields ) ); + } + + /** + * Get information about a subscription note. + * + * @since 2.0 + * @param int $subscription_id + * @param int $id + * @param array $fields + * + * @return array Subscription note + */ + public function get_subscription_note( $subscription_id, $id, $fields = null ) { + + $note = $this->get_order_note( $subscription_id, $id, $fields ); + + if ( is_wp_error( $note ) ) { + return $note; + } + + return array( 'subscription_note' => apply_filters( 'wcs_api_subscription_note_response', $note['order_note'], $subscription_id, $id, $fields ) ); + + } + + /** + * Get information about a subscription note. + * + * @param int $subscription_id + * @param int $id + * @param array $fields + * + * @return WP_Error|array Subscription note + */ + public function create_subscription_note( $subscription_id, $data ) { + + $note = $this->create_order_note( $subscription_id, $data ); + + if ( is_wp_error( $note ) ) { + return $note; + } + + do_action( 'wcs_api_created_subscription_note', $subscription_id, $note['order_note'], $this ); + + return array( 'subscription_note' => $note['order_note'] ); + } + + /** + * Verify and edit subscription note. + * + * @since 2.0 + * @param int $subscription_id + * @param int $id + * + * @return WP_Error|array Subscription note edited + */ + public function edit_subscription_note( $subscription_id, $id, $data ) { + + $note = $this->edit_order_note( $subscription_id, $id, $data ); + + if ( is_wp_error( $note ) ) { + return $note; + } + + do_action( 'wcs_api_edit_subscription_note', $subscription_id, $id, $note['order_note'], $this ); + + return array( 'subscription_note' => $note['order_note'] ); + } + + /** + * Verify and delete subscription note. + * + * @since 2.0 + * @param int $subscription_id + * @param int $id + * @return WP_Error|array deleted subscription note status + */ + public function delete_subscription_note( $subscription_id, $id ) { + + $deleted_note = $this->delete_order_note( $subscription_id, $id ); + + if ( is_wp_error( $deleted_note ) ) { + return $deleted_note; + } + + do_action( 'wcs_api_subscription_note_status', $subscription_id, $id, $this ); + + return array( 'message' => _x( 'Permanently deleted subscription note', 'API response confirming order note deleted from a subscription', 'woocommerce-subscriptions' ) ); + + } + + /** + * Get information about the initial order and renewal orders of a subscription. + * + * Called by: /subscriptions//orders + * @since 2.0 + * @param $subscription_id + * @param $fields + */ + public function get_all_subscription_orders( $subscription_id, $filters = null ) { + + $subscription_id = $this->validate_request( $subscription_id, $this->post_type, 'read' ); + + if ( is_wp_error( $subscription_id ) ) { + return $subscription_id; + } + + $subscription = wcs_get_subscription( $subscription_id ); + + $subscription_orders = $subscription->get_related_orders(); + + $formatted_orders = array(); + + if ( ! empty( $subscription_orders ) ) { + + // set post_type back to shop order so that get_orders doesn't try return a subscription. + $this->post_type = 'shop_order'; + + foreach ( $subscription_orders as $order_id ) { + $formatted_orders[] = $this->get_order( $order_id ); + } + + $this->post_type = 'shop_subscription'; + + } + + return array( 'subscription_orders' => apply_filters( 'wcs_api_subscription_orders_response', $formatted_orders, $subscription_id, $filters, $this->server ) ); + } + + /** + * Get a certain date for a subscription, if it exists, formatted for return + * + * @since 2.0 + * @param $subscription + * @param $date_type + */ + protected function get_formatted_datetime( $subscription, $date_type ) { + + $timestamp = $subscription->get_time( $date_type ); + + if ( $timestamp > 0 ) { + $formatted_datetime = $this->server->format_datetime( $timestamp ); + } else { + $formatted_datetime = ''; + } + + return $formatted_datetime; + } + + /** + * Helper method to get order post objects + * + * We need to override WC_API_Orders::query_orders() because it uses wc_get_order_statuses() + * for the query, but subscriptions use the values returned by wcs_get_subscription_statuses(). + * + * @since 2.0 + * @param array $args request arguments for filtering query + * @return WP_Query + */ + protected function query_orders( $args ) { + + // set base query arguments + $query_args = array( + 'fields' => 'ids', + 'post_type' => $this->post_type, + 'post_status' => array_keys( wcs_get_subscription_statuses() ), + ); + + // add status argument + if ( ! empty( $args['status'] ) ) { + + $statuses = 'wc-' . str_replace( ',', ',wc-', $args['status'] ); + $statuses = explode( ',', $statuses ); + $query_args['post_status'] = $statuses; + + unset( $args['status'] ); + + } + + $query_args = $this->merge_query_args( $query_args, $args ); + + return new WP_Query( $query_args ); + } + +} diff --git a/includes/class-wc-product-subscription-variation.php b/includes/class-wc-product-subscription-variation.php new file mode 100644 index 0000000..ebee278 --- /dev/null +++ b/includes/class-wc-product-subscription-variation.php @@ -0,0 +1,179 @@ +parent_product_type = $this->product_type; + + $this->product_type = 'subscription_variation'; + + $this->subscription_variation_level_meta_data = array( + 'subscription_price' => 0, + 'subscription_period' => '', + 'subscription_period_interval' => 'day', + 'subscription_length' => 0, + 'subscription_trial_length' => 0, + 'subscription_trial_period' => 'day', + 'subscription_sign_up_fee' => 0, + 'subscription_payment_sync_date' => 0, + ); + + $this->variation_level_meta_data = array_merge( $this->variation_level_meta_data, $this->subscription_variation_level_meta_data ); + } + + /** + * Get variation price HTML. Prices are not inherited from parents. + * + * @return string containing the formatted price + */ + public function get_price_html( $price = '' ) { + + $price = parent::get_price_html( $price ); + + if ( ! empty( $price ) ) { + $price = WC_Subscriptions_Product::get_price_string( $this, array( 'price' => $price ) ); + } + + return $price; + } + + /** + * Get the add to cart button text + * + * @access public + * @return string + */ + public function add_to_cart_text() { + + if ( $this->is_purchasable() && $this->is_in_stock() ) { + $text = get_option( WC_Subscriptions_Admin::$option_prefix . '_add_to_cart_button_text', __( 'Sign Up Now', 'woocommerce-subscriptions' ) ); + } else { + $text = parent::add_to_cart_text(); // translated "Read More" + } + + return apply_filters( 'woocommerce_product_add_to_cart_text', $text, $this ); + } + + /** + * Get the add to cart button text for the single page + * + * @access public + * @return string + */ + public function single_add_to_cart_text() { + return apply_filters( 'woocommerce_product_single_add_to_cart_text', self::add_to_cart_text(), $this ); + } + + /** + * Returns the sign up fee (including tax) by filtering the products price used in + * @see WC_Product::get_price_including_tax( $qty ) + * + * @return string + */ + public function get_sign_up_fee_including_tax( $qty = 1 ) { + + add_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + $sign_up_fee_including_tax = parent::get_price_including_tax( $qty ); + + remove_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + return $sign_up_fee_including_tax; + } + + /** + * Returns the sign up fee (excluding tax) by filtering the products price used in + * @see WC_Product::get_price_excluding_tax( $qty ) + * + * @return string + */ + public function get_sign_up_fee_excluding_tax( $qty = 1 ) { + + add_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + $sign_up_fee_excluding_tax = parent::get_price_excluding_tax( $qty ); + + remove_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + return $sign_up_fee_excluding_tax; + } + + /** + * Return the sign-up fee for this product + * + * @return string + */ + public function get_sign_up_fee() { + return WC_Subscriptions_Product::get_sign_up_fee( $this ); + } + + + /** + * Checks if the variable product this variation belongs to is purchasable. + * + * @access public + * @return bool + */ + function is_purchasable() { + + $purchasable = $this->parent->is_purchasable(); + + // if we have a limited subscription product, make sure the customer doesn't already have another variation for the same variable product in their cart, but only if we're not on the order received or PayPal return pages (we can't use is_order_received_page() to check that becuase get_cart_from_session() is called before the query vars are setup) + if ( 'no' != $this->parent->limit_subscriptions && ! empty( WC()->cart->cart_contents ) && ! wcs_is_order_received_page() && ! wcs_is_paypal_api_page() ) { + + foreach ( WC()->cart->cart_contents as $cart_item ) { // can't use WC()->cart->get_cart() because it will trigger an infinite loop when this is called within WC_Cart::get_cart_from_session() + + if ( $this->id == $cart_item['data']->id && $this->variation_id != $cart_item['data']->variation_id ) { + $purchasable = false; + break; + } + } + } + + return apply_filters( 'woocommerce_subscription_variation_is_purchasable', $purchasable, $this ); + } + + /** + * Checks the product type to see if it is either this product's type or the parent's + * product type. + * + * @access public + * @param mixed $type Array or string of types + * @return bool + */ + public function is_type( $type ) { + if ( $this->product_type == $type || ( is_array( $type ) && in_array( $this->product_type, $type ) ) ) { + return true; + } elseif ( $this->parent_product_type == $type || ( is_array( $type ) && in_array( $this->parent_product_type, $type ) ) ) { + return true; + } else { + return false; + } + } +} diff --git a/includes/class-wc-product-subscription.php b/includes/class-wc-product-subscription.php new file mode 100644 index 0000000..b619183 --- /dev/null +++ b/includes/class-wc-product-subscription.php @@ -0,0 +1,194 @@ +product_type = 'subscription'; + + // Load all meta fields + $this->product_custom_fields = get_post_meta( $this->id ); + + // Convert selected subscription meta fields for easy access + if ( ! empty( $this->product_custom_fields['_subscription_price'][0] ) ) { + $this->subscription_price = $this->product_custom_fields['_subscription_price'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_sign_up_fee'][0] ) ) { + $this->subscription_sign_up_fee = $this->product_custom_fields['_subscription_sign_up_fee'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_period'][0] ) ) { + $this->subscription_period = $this->product_custom_fields['_subscription_period'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_period_interval'][0] ) ) { + $this->subscription_period_interval = $this->product_custom_fields['_subscription_period_interval'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_length'][0] ) ) { + $this->subscription_length = $this->product_custom_fields['_subscription_length'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_trial_length'][0] ) ) { + $this->subscription_trial_length = $this->product_custom_fields['_subscription_trial_length'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_trial_period'][0] ) ) { + $this->subscription_trial_period = $this->product_custom_fields['_subscription_trial_period'][0]; + } + + $this->subscription_payment_sync_date = ( ! isset( $this->product_custom_fields['_subscription_payment_sync_date'][0] ) ) ? 0 : maybe_unserialize( $this->product_custom_fields['_subscription_payment_sync_date'][0] ); + + $this->subscription_one_time_shipping = ( ! isset( $this->product_custom_fields['_subscription_one_time_shipping'][0] ) ) ? 'no' : $this->product_custom_fields['_subscription_one_time_shipping'][0]; + + if ( ! isset( $this->product_custom_fields['_subscription_limit'][0] ) ) { + $this->limit_subscriptions = 'no'; + } elseif ( 'yes' == $this->product_custom_fields['_subscription_limit'][0] ) { // backward compatibility + $this->limit_subscriptions = 'any'; + } else { + $this->limit_subscriptions = $this->product_custom_fields['_subscription_limit'][0]; + } + + } + + /** + * Get subscription's price HTML. + * + * @return string containing the formatted price + */ + public function get_price_html( $price = '' ) { + + $price = parent::get_price_html( $price ); + + if ( ! empty( $price ) ) { + $price = WC_Subscriptions_Product::get_price_string( $this, array( 'price' => $price ) ); + } + + return $price; + } + + /** + * Get the add to cart button text + * + * @access public + * @return string + */ + public function add_to_cart_text() { + + if ( $this->is_purchasable() && $this->is_in_stock() ) { + $text = get_option( WC_Subscriptions_Admin::$option_prefix . '_add_to_cart_button_text', __( 'Sign Up Now', 'woocommerce-subscriptions' ) ); + } else { + $text = parent::add_to_cart_text(); // translated "Read More" + } + + return apply_filters( 'woocommerce_product_add_to_cart_text', $text, $this ); + } + + /** + * Get the add to cart button text for the single page + * + * @access public + * @return string + */ + public function single_add_to_cart_text() { + return apply_filters( 'woocommerce_product_single_add_to_cart_text', self::add_to_cart_text(), $this ); + } + + /** + * Returns the sign up fee (including tax) by filtering the products price used in + * @see WC_Product::get_price_including_tax( $qty ) + * + * @return string + */ + public function get_sign_up_fee_including_tax( $qty = 1 ) { + + add_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + $sign_up_fee_including_tax = parent::get_price_including_tax( $qty ); + + remove_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + return $sign_up_fee_including_tax; + } + + /** + * Returns the sign up fee (excluding tax) by filtering the products price used in + * @see WC_Product::get_price_excluding_tax( $qty ) + * + * @return string + */ + public function get_sign_up_fee_excluding_tax( $qty = 1 ) { + + add_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + $sign_up_fee_excluding_tax = parent::get_price_excluding_tax( $qty ); + + remove_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + return $sign_up_fee_excluding_tax; + } + + /** + * Return the sign-up fee for this product + * + * @return string + */ + public function get_sign_up_fee() { + return WC_Subscriptions_Product::get_sign_up_fee( $this ); + } + + /** + * Checks if the store manager has requested the current product be limited to one purchase + * per customer, and if so, checks whether the customer already has an active subscription to + * the product. + * + * @access public + * @return bool + */ + function is_purchasable() { + + $purchasable = parent::is_purchasable(); + + if ( true === $purchasable && false === WC_Subscriptions_Product::is_purchasable( $purchasable, $this ) ) { + $purchasable = false; + } + + return apply_filters( 'woocommerce_subscription_is_purchasable', $purchasable, $this ); + } +} diff --git a/includes/class-wc-product-variable-subscription.php b/includes/class-wc-product-variable-subscription.php new file mode 100644 index 0000000..be1f940 --- /dev/null +++ b/includes/class-wc-product-variable-subscription.php @@ -0,0 +1,585 @@ +parent_product_type = $this->product_type; + + $this->product_type = 'variable-subscription'; + + // Load all meta fields + $this->product_custom_fields = get_post_meta( $this->id ); + + // Convert selected subscription meta fields for easy access + if ( ! empty( $this->product_custom_fields['_subscription_price'][0] ) ) { + $this->subscription_price = $this->product_custom_fields['_subscription_price'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_sign_up_fee'][0] ) ) { + $this->subscription_sign_up_fee = $this->product_custom_fields['_subscription_sign_up_fee'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_period'][0] ) ) { + $this->subscription_period = $this->product_custom_fields['_subscription_period'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_period_interval'][0] ) ) { + $this->subscription_period_interval = $this->product_custom_fields['_subscription_period_interval'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_length'][0] ) ) { + $this->subscription_length = $this->product_custom_fields['_subscription_length'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_trial_length'][0] ) ) { + $this->subscription_trial_length = $this->product_custom_fields['_subscription_trial_length'][0]; + } + + if ( ! empty( $this->product_custom_fields['_subscription_trial_period'][0] ) ) { + $this->subscription_trial_period = $this->product_custom_fields['_subscription_trial_period'][0]; + } + + $this->subscription_payment_sync_date = 0; + + $this->subscription_one_time_shipping = ( ! isset( $this->product_custom_fields['_subscription_one_time_shipping'][0] ) ) ? 'no' : $this->product_custom_fields['_subscription_one_time_shipping'][0]; + + if ( ! isset( $this->product_custom_fields['_subscription_limit'][0] ) ) { + $this->limit_subscriptions = 'no'; + } elseif ( 'yes' == $this->product_custom_fields['_subscription_limit'][0] ) { // backward compatibility + $this->limit_subscriptions = 'any'; + } else { + $this->limit_subscriptions = $this->product_custom_fields['_subscription_limit'][0]; + } + + add_filter( 'woocommerce_add_to_cart_handler', array( &$this, 'add_to_cart_handler' ), 10, 2 ); + } + + /** + * Get the add to cart button text for the single page + * + * @access public + * @return string + */ + public function single_add_to_cart_text() { + + if ( $this->is_purchasable() && $this->is_in_stock() ) { + $text = get_option( WC_Subscriptions_Admin::$option_prefix . '_add_to_cart_button_text', __( 'Sign Up Now', 'woocommerce-subscriptions' ) ); + } else { + $text = parent::add_to_cart_text(); // translated "Read More" + } + + return apply_filters( 'woocommerce_product_single_add_to_cart_text', $text, $this ); + } + + /** + * Sync variable product prices with the childs lowest/highest prices. + * + * @access public + * @return void + */ + public function variable_product_sync( $product_id = '' ) { + + parent::variable_product_sync(); + + $children = get_posts( array( + 'post_parent' => $this->id, + 'posts_per_page' => -1, + 'post_type' => 'product_variation', + 'fields' => 'ids', + 'post_status' => 'publish', + ) ); + + $lowest_initial_amount = $highest_initial_amount = $lowest_price = $highest_price = ''; + $shortest_initial_period = $longest_initial_period = $shortest_trial_period = $longest_trial_period = $shortest_trial_length = $longest_trial_length = ''; + $longest_initial_interval = $shortest_initial_interval = $variable_subscription_period = $variable_subscription_period_interval = ''; + $lowest_regular_price = $highest_regular_price = $lowest_sale_price = $highest_sale_price = $max_subscription_period = $max_subscription_period_interval = ''; + $variable_subscription_sign_up_fee = $variable_subscription_trial_period = $variable_subscription_trial_length = $variable_subscription_length = $variable_subscription_sign_up_fee = $variable_subscription_trial_period = $variable_subscription_trial_length = $variable_subscription_length = ''; + $min_variation_id = $max_variation_id = null; + + if ( $children ) { + + foreach ( $children as $child ) { + + $is_max = $is_min = false; + + // WC has already determined the correct price which accounts for sale price + $child_price = get_post_meta( $child, '_price', true ); + + $child_billing_period = get_post_meta( $child, '_subscription_period', true ); + $child_billing_interval = get_post_meta( $child, '_subscription_period_interval', true ); + $child_sign_up_fee = get_post_meta( $child, '_subscription_sign_up_fee', true ); + $child_free_trial_length = get_post_meta( $child, '_subscription_trial_length', true ); + $child_free_trial_period = get_post_meta( $child, '_subscription_trial_period', true ); + + if ( '' === $child_price && '' === $child_sign_up_fee ) { + continue; + } + + $child_price = ( '' === $child_price ) ? 0 : $child_price; + $child_sign_up_fee = ( '' === $child_sign_up_fee ) ? 0 : $child_sign_up_fee; + + $has_free_trial = ( '' !== $child_free_trial_length && $child_free_trial_length > 0 ) ? true : false; + + // Determine some recurring price flags + $is_lowest_price = ( $child_price < $lowest_price || '' === $lowest_price ) ? true : false; + $is_longest_period = ( WC_Subscriptions::get_longest_period( $variable_subscription_period, $child_billing_period ) === $child_billing_period ) ? true : false; + $is_longest_interval = ( $child_billing_interval >= $variable_subscription_period_interval || '' === $variable_subscription_period_interval ) ? true : false; + + // Find the amount the subscriber will have to pay up-front + if ( $has_free_trial ) { + $initial_amount = $child_sign_up_fee; + $initial_period = $child_free_trial_period; + $initial_interval = $child_free_trial_length; + } else { + $initial_amount = $child_price + $child_sign_up_fee; + $initial_period = $child_billing_period; + $initial_interval = $child_billing_interval; + } + + // We have a free trial & no sign-up fee, so need to choose the longest free trial (and maybe the shortest) + if ( $has_free_trial && 0 == $child_sign_up_fee ) { + + // First variation + if ( '' === $longest_trial_period ) { + + $is_min = true; + + // If two variations have the same free trial, choose the variation with the lowest recurring price for the longest period + } elseif ( $variable_subscription_trial_period === $child_free_trial_period && $child_free_trial_length === $variable_subscription_trial_length ) { + + // If the variation has the lowest recurring price, it's the cheapest + if ( $is_lowest_price ) { + + $is_min = true; + + // When current variation's free trial is the same as the lowest, it's the cheaper if it has a longer billing schedule + } elseif ( $child_price === $lowest_price ) { + + if ( $is_longest_period && $is_longest_interval ) { + + $is_min = true; + + // Longest with a new billing period + } elseif ( $is_longest_period && $child_billing_period !== $variable_subscription_trial_period ) { + + $is_min = true; + + } + } + + // Otherwise the cheapest variation is the one with the longer trial + } elseif ( $variable_subscription_trial_period === $child_free_trial_period ) { + + $is_min = ( $child_free_trial_length > $variable_subscription_trial_length ) ? true : false; + + // Otherwise just a longer trial period (that isn't equal to the longest period) + } elseif ( WC_Subscriptions::get_longest_period( $longest_trial_period, $child_free_trial_period ) === $child_free_trial_period ) { + + $is_min = true; + + } + + if ( $is_min ) { + $longest_trial_period = $child_free_trial_period; + $longest_trial_length = $child_free_trial_length; + } + + // If the current cheapest variation is also free then the shortest trial period is the most expensive + if ( 0 == $lowest_price || '' === $lowest_price ) { + + if ( '' === $shortest_trial_period ) { + + $is_max = true; + + // Need to check trial length + } elseif ( $shortest_trial_period === $child_free_trial_period ) { + + $is_max = ( $child_free_trial_length < $shortest_trial_length ) ? true : false; + + // Need to find shortest period + } elseif ( WC_Subscriptions::get_shortest_period( $shortest_trial_period, $child_free_trial_period ) === $child_free_trial_period ) { + + $is_max = true; + + } + + if ( $is_max ) { + $shortest_trial_period = $child_free_trial_period; + $shortest_trial_length = $child_free_trial_length; + } + } + } else { + + $longest_initial_period = WC_Subscriptions::get_longest_period( $longest_initial_period, $initial_period ); + $shortest_initial_period = WC_Subscriptions::get_shortest_period( $shortest_initial_period, $initial_period ); + + $is_lowest_initial_amount = ( $initial_amount < $lowest_initial_amount || '' === $lowest_initial_amount ) ? true : false; + $is_longest_initial_period = ( $initial_period === $longest_initial_period ) ? true : false; + $is_longest_initial_interval = ( $initial_interval >= $longest_initial_interval || '' === $longest_initial_interval ) ? true : false; + + $is_highest_initial = ( $initial_amount > $highest_initial_amount || '' === $highest_initial_amount ) ? true : false; + $is_shortest_period = ( $initial_period === $shortest_initial_period || '' === $shortest_initial_period ) ? true : false; + $is_shortest_interval = ( $initial_interval < $shortest_initial_interval || '' === $shortest_initial_interval ) ? true : false; + + // If we're not dealing with the lowest initial access amount, then ignore this variation + if ( ! $is_lowest_initial_amount && $initial_amount !== $lowest_initial_amount ) { + continue; + } + + // If the variation has the lowest price, it's the cheapest + if ( $is_lowest_initial_amount ) { + + $is_min = true; + + // When current variation's price is the same as the lowest, it's the cheapest only if it has a longer billing schedule + } elseif ( $initial_amount === $lowest_initial_amount ) { + + // We need to check the recurring schedule when the sign-up fee & free trial periods are equal + if ( $has_free_trial && $initial_period == $longest_initial_period && $initial_interval == $longest_initial_interval ) { + + // If the variation has the lowest recurring price, it's the cheapest + if ( $is_lowest_price ) { + + $is_min = true; + + // When current variation's price is the same as the lowest, it's the cheapest only if it has a longer billing schedule + } elseif ( $child_price === $lowest_price ) { + + if ( $is_longest_period && $is_longest_interval ) { + + $is_min = true; + + // Longest with a new billing period + } elseif ( $is_longest_period && $child_billing_period !== $variable_subscription_period ) { + + $is_min = true; + + } + } + + // Longest initial term is the cheapest + } elseif ( $is_longest_initial_period && $is_longest_initial_interval ) { + + $is_min = true; + + // Longest with a new billing period + } elseif ( $is_longest_initial_period && $initial_period !== $variable_subscription_period ) { + + $is_min = true; + + } + } + + // If we have the highest price for the shortest period, we might have the maximum variation + if ( $is_highest_initial && $is_shortest_period && $is_shortest_interval ) { + + $is_max = true; + + // But only if its for the shortest billing period + } elseif ( $child_price === $highest_price ) { + + if ( $is_shortest_period && $is_shortest_interval ) { + $is_max = true; + } elseif ( $is_shortest_period ) { + $is_max = true; + } + } + } + + // If it's the min subscription terms + if ( $is_min ) { + + $min_variation_id = $child; + + $lowest_price = $child_price; + $lowest_regular_price = get_post_meta( $child, '_regular_price', true ); + $lowest_sale_price = get_post_meta( $child, '_sale_price', true ); + + $lowest_regular_price = ( '' === $lowest_regular_price ) ? 0 : $lowest_regular_price; + $lowest_sale_price = ( '' === $lowest_sale_price ) ? 0 : $lowest_sale_price; + + $lowest_initial_amount = $initial_amount; + $longest_initial_period = $initial_period; + $longest_initial_interval = $initial_interval; + + $variable_subscription_sign_up_fee = $child_sign_up_fee; + $variable_subscription_period = $child_billing_period; + $variable_subscription_period_interval = $child_billing_interval; + $variable_subscription_trial_length = $child_free_trial_length; + $variable_subscription_trial_period = $child_free_trial_period; + $variable_subscription_length = get_post_meta( $child, '_subscription_length', true ); + } + + if ( $is_max ) { + + $max_variation_id = $child; + + $highest_price = $child_price; + $highest_regular_price = get_post_meta( $child, '_regular_price', true ); + $highest_sale_price = get_post_meta( $child, '_sale_price', true ); + $highest_initial_amount = $initial_amount; + + $highest_regular_price = ( '' === $highest_regular_price ) ? 0 : $highest_regular_price; + $highest_sale_price = ( '' === $highest_sale_price ) ? 0 : $highest_sale_price; + + $max_subscription_period = $child_billing_period; + $max_subscription_period_interval = $child_billing_interval; + } + } + + update_post_meta( $this->id, '_min_price_variation_id', $min_variation_id ); + update_post_meta( $this->id, '_max_price_variation_id', $max_variation_id ); + + update_post_meta( $this->id, '_price', $lowest_price ); + update_post_meta( $this->id, '_min_variation_price', $lowest_price ); + update_post_meta( $this->id, '_max_variation_price', $highest_price ); + update_post_meta( $this->id, '_min_variation_regular_price', $lowest_regular_price ); + update_post_meta( $this->id, '_max_variation_regular_price', $highest_regular_price ); + update_post_meta( $this->id, '_min_variation_sale_price', $lowest_sale_price ); + update_post_meta( $this->id, '_max_variation_sale_price', $highest_sale_price ); + + update_post_meta( $this->id, '_min_variation_period', $variable_subscription_period ); + update_post_meta( $this->id, '_max_variation_period', $variable_subscription_period_interval ); + update_post_meta( $this->id, '_min_variation_period_interval', $max_subscription_period ); + update_post_meta( $this->id, '_max_variation_period_interval', $max_subscription_period_interval ); + + update_post_meta( $this->id, '_subscription_price', $lowest_price ); + update_post_meta( $this->id, '_subscription_sign_up_fee', $variable_subscription_sign_up_fee ); + update_post_meta( $this->id, '_subscription_period', $variable_subscription_period ); + update_post_meta( $this->id, '_subscription_period_interval', $variable_subscription_period_interval ); + update_post_meta( $this->id, '_subscription_trial_period', $variable_subscription_trial_period ); + update_post_meta( $this->id, '_subscription_trial_length', $variable_subscription_trial_length ); + update_post_meta( $this->id, '_subscription_length', $variable_subscription_length ); + + $this->subscription_price = $lowest_price; + $this->subscription_sign_up_fee = $variable_subscription_sign_up_fee; + $this->subscription_period = $variable_subscription_period; + $this->subscription_period_interval = $variable_subscription_period_interval; + $this->subscription_trial_period = $variable_subscription_trial_period; + $this->subscription_trial_length = $variable_subscription_trial_length; + $this->subscription_length = $variable_subscription_length; + + if ( function_exists( 'wc_delete_product_transients' ) ) { + wc_delete_product_transients( $this->id ); + } else { + WC()->clear_product_transients( $this->id ); + } + } else { // No variations yet + + $this->subscription_price = ''; + $this->subscription_sign_up_fee = ''; + $this->subscription_period = 'day'; + $this->subscription_period_interval = 1; + $this->subscription_trial_period = 'day'; + $this->subscription_trial_length = 1; + $this->subscription_length = 0; + + } + + } + + /** + * Returns the price in html format. + * + * @access public + * @param string $price (default: '') + * @return string + */ + public function get_price_html( $price = '' ) { + + $price = parent::get_price_html( $price ); + + if ( ! isset( $this->subscription_period ) || ! isset( $this->subscription_period_interval ) || ! isset( $this->max_variation_period ) || ! isset( $this->max_variation_period_interval ) ) { + $this->variable_product_sync(); + } + + // Only create the subscription price string when a price has been set + if ( $this->subscription_price !== '' || $this->subscription_sign_up_fee !== '' ) { + + $price = ''; + + if ( $this->is_on_sale() && isset( $this->min_variation_price ) && $this->min_variation_regular_price !== $this->get_price() ) { + + if ( ! $this->min_variation_price || $this->min_variation_price !== $this->max_variation_price ) { + $price .= $this->get_price_html_from_text(); + } + + $variation_id = get_post_meta( $this->id, '_min_price_variation_id', true ); + $variation = $this->get_child( $variation_id ); + $tax_display_mode = get_option( 'woocommerce_tax_display_shop' ); + + $sale_price = 'incl' == $tax_display_mode ? $variation->get_price_including_tax( 1, $variation->get_sale_price() ) : $variation->get_price_excluding_tax( 1, $variation->get_sale_price() ); + $regular_price = 'incl' == $tax_display_mode ? $variation->get_price_including_tax( 1, $variation->get_regular_price() ) : $variation->get_price_excluding_tax( 1, $variation->get_regular_price() ); + + $price .= $this->get_price_html_from_to( $regular_price, $sale_price ); + + } else { + + if ( $this->min_variation_price !== $this->max_variation_price ) { + $price .= $this->get_price_html_from_text(); + } + + $price .= wc_price( $this->get_variation_price( 'min', true ) ); + + } + + // Make sure the price contains "From:" when billing schedule differs between variations + if ( false === strpos( $price, $this->get_price_html_from_text() ) ) { + if ( $this->subscription_period !== $this->max_variation_period ) { + $price = $this->get_price_html_from_text() . $price; + } elseif ( $this->subscription_period_interval !== $this->max_variation_period_interval ) { + $price = $this->get_price_html_from_text() . $price; + } + } + + $price .= $this->get_price_suffix(); + + $price = WC_Subscriptions_Product::get_price_string( $this, array( 'price' => $price ) ); + } + + return apply_filters( 'woocommerce_variable_subscription_price_html', $price, $this ); + } + + /** + * Returns the sign up fee (including tax) by filtering the products price used in + * @see WC_Product::get_price_including_tax( $qty ) + * + * @return string + */ + public function get_sign_up_fee_including_tax( $qty = 1 ) { + + add_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + $sign_up_fee_including_tax = parent::get_price_including_tax( $qty ); + + remove_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + return $sign_up_fee_including_tax; + } + + /** + * Returns the sign up fee (excluding tax) by filtering the products price used in + * @see WC_Product::get_price_excluding_tax( $qty ) + * + * @return string + */ + public function get_sign_up_fee_excluding_tax( $qty = 1 ) { + + add_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + $sign_up_fee_excluding_tax = parent::get_price_excluding_tax( $qty ); + + remove_filter( 'woocommerce_get_price', array( &$this, 'get_sign_up_fee' ), 100, 0 ); + + return $sign_up_fee_excluding_tax; + } + + /** + * Return the sign-up fee for this product + * + * @return string + */ + public function get_sign_up_fee() { + return WC_Subscriptions_Product::get_sign_up_fee( $this ); + } + + /** + * get_child function. + * + * @access public + * @param mixed $child_id + * @return object WC_Product_Subscription or WC_Product_Subscription_Variation + */ + public function get_child( $child_id ) { + return wc_get_product( $child_id, array( + 'product_type' => 'Subscription_Variation', + 'parent_id' => $this->id, + 'parent' => $this, + ) ); + } + + /** + * + * @param string $product_type A string representation of a product type + */ + public function add_to_cart_handler( $handler, $product ) { + + if ( 'variable-subscription' === $handler ) { + $handler = 'variable'; + } + + return $handler; + } + + /** + * Checks if the store manager has requested the current product be limited to one purchase + * per customer, and if so, checks whether the customer already has an active subscription to + * the product. + * + * @access public + * @return bool + */ + function is_purchasable() { + + $purchasable = parent::is_purchasable(); + + if ( true === $purchasable && false === WC_Subscriptions_Product::is_purchasable( $purchasable, $this ) ) { + $purchasable = false; + } + + return apply_filters( 'woocommerce_subscription_is_purchasable', $purchasable, $this ); + } + + /** + * Checks the product type to see if it is either this product's type or the parent's + * product type. + * + * @access public + * @param mixed $type Array or string of types + * @return bool + */ + public function is_type( $type ) { + if ( $this->product_type == $type || ( is_array( $type ) && in_array( $this->product_type, $type ) ) ) { + return true; + } elseif ( $this->parent_product_type == $type || ( is_array( $type ) && in_array( $this->parent_product_type, $type ) ) ) { + return true; + } else { + return false; + } + } +} diff --git a/includes/class-wc-subscription.php b/includes/class-wc-subscription.php new file mode 100644 index 0000000..1ecfafc --- /dev/null +++ b/includes/class-wc-subscription.php @@ -0,0 +1,1732 @@ +order_type = 'shop_subscription'; + + $this->schedule = new stdClass(); + } + + /** + * Populates a subscription from the loaded post data. + * + * @param mixed $result + */ + public function populate( $result ) { + parent::populate( $result ); + + if ( $this->post->post_parent > 0 ) { + $this->order = wc_get_order( $this->post->post_parent ); + } + } + + /** + * __isset function. + * + * @param mixed $key + * @return mixed + */ + public function __isset( $key ) { + + if ( in_array( $key, array( 'start_date', 'trial_end_date', 'next_payment_date', 'end_date', 'last_payment_date', 'order', 'payment_gateway' ) ) ) { + + $is_set = true; + + } else { + + $is_set = parent::__isset( $key ); + + } + + return $is_set; + } + + /** + * __get function. + * + * @param mixed $key + * @return mixed + */ + public function __get( $key ) { + + if ( in_array( $key, array( 'start_date', 'trial_end_date', 'next_payment_date', 'end_date', 'last_payment_date' ) ) ) { + + $value = $this->get_date( $key ); + + } elseif ( 'payment_gateway' == $key ) { + + // Only set the payment gateway once and only when we first need it + if ( ! property_exists( $this, 'payment_gateway' ) || empty( $this->payment_gateway ) ) { + $this->payment_gateway = wc_get_payment_gateway_by_order( $this ); + } + + $value = $this->payment_gateway; + + } else { + + $value = parent::__get( $key ); + + } + + return $value; + } + + /** + * Set or change the WC_Order ID which records the subscription's initial purchase. + * + * @param int $post_id + */ + public function update_parent( $order_id ) { + + // Update the parent in the database + wp_update_post( array( + 'ID' => $this->id, + 'post_parent' => $order_id, + ) ); + + // And update the parent in memory + $this->post->post_parent = $order_id; + $this->order = wc_get_order( $order_id ); + } + + /** + * Checks if the subscription has an unpaid order or renewal order (and therefore, needs payment). + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @return bool True if the subscription has an unpaid renewal order, false if the subscription has no unpaid renewal orders. + * @since 2.0 + */ + public function needs_payment() { + + $needs_payment = false; + + // First check if the subscription is pending or failed or is for $0 + if ( parent::needs_payment() ) { + + $needs_payment = true; + + // Now make sure the parent order doesn't need payment + } elseif ( false !== $this->order && ( $this->order->needs_payment() || $this->order->has_status( 'on-hold' ) ) ) { + + $needs_payment = true; + + // And finally, check that the latest order (switch or renewal) doesn't need payment + } else { + + $last_order_id = get_posts( array( + 'posts_per_page' => 1, + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'ID', + 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => '=', + 'value' => $this->id, + 'type' => 'numeric', + ), + array( + 'key' => '_subscription_switch', + 'compare' => '=', + 'value' => $this->id, + 'type' => 'numeric', + ), + 'relation' => 'OR', + ), + ) ); + + if ( ! empty( $last_order_id ) ) { + + $order = new WC_Order( $last_order_id[0] ); + + if ( $order->needs_payment() || $order->has_status( array( 'on-hold', 'failed', 'cancelled' ) ) ) { + $needs_payment = true; + } + } + } + + return apply_filters( 'woocommerce_subscription_needs_payment', $needs_payment, $this ); + } + + /** + * Check if the subscription's payment method supports a certain feature, like date changes. + * + * If the subscription uses manual renewals as the payment method, it supports all features. + * Otherwise, the feature will only be supported if the payment gateway set as the payment + * method supports for the feature. + * + * @param string $payment_gateway_feature one of: + * 'subscription_suspension' + * 'subscription_reactivation' + * 'subscription_cancellation' + * 'subscription_date_changes' + * 'subscription_amount_changes' + * @since 2.0 + */ + public function payment_method_supports( $payment_gateway_feature ) { + + if ( $this->is_manual() || ( ! empty( $this->payment_gateway ) && $this->payment_gateway->supports( $payment_gateway_feature ) ) ) { + $payment_gateway_supports = true; + } else { + $payment_gateway_supports = false; + } + + return apply_filters( 'woocommerce_subscription_payment_gateway_supports', $payment_gateway_supports, $payment_gateway_feature, $this ); + } + + /** + * Check if a the subscription can be changed to a new status or date + */ + public function can_be_updated_to( $new_status ) { + + $new_status = ( 'wc-' === substr( $new_status, 0, 3 ) ) ? substr( $new_status, 3 ) : $new_status; + + switch ( $new_status ) { + case 'pending' : + if ( $this->has_status( array( 'auto-draft', 'draft' ) ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'completed' : // core WC order status mapped internally to avoid exceptions + case 'active' : + if ( $this->payment_method_supports( 'subscription_reactivation' ) && $this->has_status( 'on-hold' ) ) { + $can_be_updated = true; + } elseif ( $this->has_status( 'pending' ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'failed' : // core WC order status mapped internally to avoid exceptions + case 'on-hold' : + if ( $this->payment_method_supports( 'subscription_suspension' ) && $this->has_status( array( 'active', 'pending' ) ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'cancelled' : + if ( $this->payment_method_supports( 'subscription_cancellation' ) && ( $this->has_status( 'pending-cancel' ) || ! $this->has_status( wcs_get_subscription_ended_statuses() ) ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'pending-cancel' : + // Only active subscriptions can be given the "pending cancellation" status, becuase it is used to account for a prepaid term + if ( $this->payment_method_supports( 'subscription_cancellation' ) && $this->has_status( 'active' ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'expired' : + if ( ! $this->has_status( array( 'cancelled', 'trash', 'switched' ) ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'trash' : + if ( $this->has_status( wcs_get_subscription_ended_statuses() ) || $this->can_be_updated_to( 'cancelled' ) ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + case 'deleted' : + if ( 'trash' == $this->get_status() ) { + $can_be_updated = true; + } else { + $can_be_updated = false; + } + break; + default : + $can_be_updated = apply_filters( 'woocommerce_can_subscription_be_updated_to', false, $new_status, $this ); + break; + } + + return apply_filters( 'woocommerce_can_subscription_be_updated_to_' . $new_status, $can_be_updated, $this ); + } + + /** + * Updates status of the subscription + * + * @param string $new_status Status to change the order to. No internal wc- prefix is required. + * @param string $note (default: '') Optional note to add + */ + public function update_status( $new_status, $note = '', $manual = false ) { + + if ( ! $this->id ) { + return; + } + + // Standardise status names. + $new_status = ( 'wc-' === substr( $new_status, 0, 3 ) ) ? substr( $new_status, 3 ) : $new_status; + $new_status_key = 'wc-' . $new_status; + $old_status = ( 'wc-' === substr( $this->get_status(), 0, 3 ) ) ? substr( $this->get_status(), 3 ) : $this->get_status(); + $old_status_key = 'wc-' . $old_status; + + if ( $new_status !== $old_status || ! in_array( $old_status_key, array_keys( wcs_get_subscription_statuses() ) ) ) { + + // Only update is possible + if ( ! $this->can_be_updated_to( $new_status ) ) { + + $message = sprintf( __( 'Unable to change subscription status to "%s".', 'woocommerce-subscriptions' ), $new_status ); + + $this->add_order_note( $message ); + + do_action( 'woocommerce_subscription_unable_to_update_status', $this, $new_status, $old_status ); + + // Let plugins handle it if they tried to change to an invalid status + throw new Exception( $message ); + + } + + try { + + wp_update_post( array( 'ID' => $this->id, 'post_status' => $new_status_key ) ); + $this->post_status = $new_status_key; + + switch ( $new_status ) { + + case 'pending' : + // Nothing to do here + break; + + case 'pending-cancel' : + + $end_date = $this->calculate_date( 'end_of_prepaid_term' ); + + // If there is no future payment and no expiration date set, the customer has no prepaid term (this shouldn't be possible as only active subscriptions can be set to pending cancellation and an active subscription always has either an end date or next payment) + if ( 0 == $end_date ) { + $end_date = current_time( 'mysql', true ); + } + + $this->delete_date( 'trial_end' ); + $this->delete_date( 'next_payment' ); + $this->update_dates( array( 'end' => $end_date ) ); + break; + + case 'completed' : // core WC order status mapped internally to avoid exceptions + case 'active' : + // Recalculate and set next payment date + $stored_next_payment = $this->get_time( 'next_payment' ); + + // Make sure the next payment date is more than 2 hours in the future + if ( $stored_next_payment < ( gmdate( 'U' ) + 2 * HOUR_IN_SECONDS ) ) { // also accounts for a $stored_next_payment of 0, meaning it's not set + + $calculated_next_payment = $this->calculate_date( 'next_payment' ); + + if ( $calculated_next_payment > 0 ) { + $this->update_dates( array( 'next_payment' => $calculated_next_payment ) ); + } elseif ( $stored_next_payment < gmdate( 'U' ) ) { // delete the stored date if it's in the past as we're not updating it (the calculated next payment date is 0 or none) + $this->delete_date( 'next_payment' ); + } + } + // Trial end date and end/expiration date don't change at all - they should be set when the subscription is first created + wcs_make_user_active( $this->customer_user ); + break; + + case 'failed' : // core WC order status mapped internally to avoid exceptions + case 'on-hold' : + // Record date of suspension - 'post_modified' column? + $this->update_suspension_count( $this->suspension_count + 1 ); + wcs_maybe_make_user_inactive( $this->customer_user ); + break; + case 'cancelled' : + case 'switched' : + case 'expired' : + $this->delete_date( 'trial_end' ); + $this->delete_date( 'next_payment' ); + $this->update_dates( array( 'end' => current_time( 'mysql', true ) ) ); + wcs_maybe_make_user_inactive( $this->customer_user ); + break; + } + + // translators: $1 note why the status changes (if any), $2: old status, $3: new status + $this->add_order_note( trim( sprintf( __( '%1$s Status changed from %2$s to %3$s.', 'woocommerce-subscriptions' ), $note, wcs_get_subscription_status_name( $old_status ), wcs_get_subscription_status_name( $new_status ) ) ), 0, $manual ); + + // dynamic hooks for convenience + do_action( 'woocommerce_subscription_status_' . $new_status, $this ); + do_action( 'woocommerce_subscription_status_' . $old_status . '_to_' . $new_status, $this ); + + // Trigger a hook with params we want + do_action( 'woocommerce_subscription_status_updated', $this, $new_status, $old_status ); + + // Trigger a hook with params matching WooCommerce's 'woocommerce_order_status_changed' hook so functions attached to it can be attached easily to subscription status changes + do_action( 'woocommerce_subscription_status_changed', $this->id, $old_status, $new_status ); + + } catch ( Exception $e ) { + + // Make sure the old status is restored + wp_update_post( array( 'ID' => $this->id, 'post_status' => $old_status_key ) ); + $this->post_status = $old_status_key; + + $this->add_order_note( sprintf( __( 'Unable to change subscription status to "%s".', 'woocommerce-subscriptions' ), $new_status ) ); + + do_action( 'woocommerce_subscription_unable_to_update_status', $this, $new_status, $old_status ); + + throw $e; + } + } + } + + /** + * Checks if the subscription requires manual renewal payments. + * + * @access public + * @return bool + */ + public function is_manual() { + + if ( WC_Subscriptions::is_duplicate_site() || empty( $this->payment_gateway ) || ( isset( $this->requires_manual_renewal ) && 'true' == $this->requires_manual_renewal ) ) { + $is_manual = true; + } else { + $is_manual = false; + } + + return $is_manual; + } + + /** + * Checks if the subscription requires manual renewal payments. + * + * @access public + * @return bool + */ + public function update_manual( $is_manual = true ) { + + if ( true === $is_manual || 'true' === $is_manual ) { + $this->requires_manual_renewal = 'true'; + update_post_meta( $this->id, '_requires_manual_renewal', 'true' ); + } else { + $this->requires_manual_renewal = 'false'; + update_post_meta( $this->id, '_requires_manual_renewal', 'false' ); + } + + return $is_manual; + } + + /** + * Overrides the WC Order get_status function for draft and auto-draft statuses for a subscription + * so that it will return a pending status instead of draft / auto-draft. + * + * @since 2.0 + * @return string Status + */ + public function get_status() { + if ( in_array( get_post_status( $this->id ), array( 'draft', 'auto-draft' ) ) ) { + $this->post_status = 'wc-pending'; + $status = apply_filters( 'woocommerce_order_get_status', 'pending', $this ); + } else { + $status = parent::get_status(); + } + + return $status; + } + + /** + * WooCommerce handles statuses without the wc- prefix in has_status, get_status and update_status, however in the database + * it stores it with the prefix. This makes it hard to use the same filters / status names in both WC's methods AND WP's + * get_posts functions. This function bridges that gap and returns the prefixed versions of completed statuses. + * + * @since 2.0 + * @return array By default: wc-processing and wc-completed + */ + public function get_paid_order_statuses() { + $paid_statuses = array( + 'processing', + 'completed', + 'wc-processing', + 'wc-completed', + ); + + $custom_status = apply_filters( 'woocommerce_payment_complete_order_status', 'completed', $this->id ); + + if ( '' !== $custom_status && ! in_array( $custom_status, $paid_statuses ) && ! in_array( 'wc-' . $custom_status, $paid_statuses ) ) { + $paid_statuses[] = $custom_status; + $paid_statuses[] = 'wc-' . $custom_status; + } + + return apply_filters( 'woocommerce_subscriptions_paid_order_statuses', $paid_statuses, $this ); + } + + /** + * Get the number of payments completed for a subscription + * + * Completed payment include all renewal orders and potentially an initial order (if the + * subscription was created as a result of a purchase from the front end rather than + * manually by the store manager). + * + * @since 2.0 + */ + public function get_completed_payment_count() { + + $completed_payment_count = ( false !== $this->order && ( isset( $this->order->paid_date ) || $this->order->has_status( $this->get_paid_order_statuses() ) ) ) ? 1 : 0; + + // not all gateways will call $order->payment_complete() so we need to find renewal orders with a paid status rather than just a _paid_date + $paid_status_renewal_orders = get_posts( array( + 'posts_per_page' => -1, + 'post_status' => $this->get_paid_order_statuses(), + 'post_type' => 'shop_order', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'desc', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => '=', + 'value' => $this->id, + 'type' => 'numeric', + ), + ), + ) ); + + // because some stores may be using custom order status plugins, we also can't rely on order status to find paid orders, so also check for a _paid_date + $paid_date_renewal_orders = get_posts( array( + 'posts_per_page' => -1, + 'post_status' => 'any', + 'post_type' => 'shop_order', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'desc', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => '=', + 'value' => $this->id, + 'type' => 'numeric', + ), + array( + 'key' => '_paid_date', + 'compare' => 'EXISTS', + ), + ), + ) ); + + $paid_renewal_orders = array_unique( array_merge( $paid_date_renewal_orders, $paid_status_renewal_orders ) ); + + if ( ! empty( $paid_renewal_orders ) ) { + $completed_payment_count += count( $paid_renewal_orders ); + } + + return apply_filters( 'woocommerce_subscription_payment_completed_count', $completed_payment_count, $this ); + } + + /** + * Get the number of payments failed + * + * Failed orders are the number of orders that have wc-failed as the status + * + * @since 2.0 + */ + public function get_failed_payment_count() { + + $failed_payment_count = ( false !== $this->order && $this->order->has_status( 'wc-failed' ) ) ? 1 : 0; + + $failed_renewal_orders = get_posts( array( + 'posts_per_page' => -1, + 'post_status' => 'wc-failed', + 'post_type' => 'shop_order', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'desc', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => '=', + 'value' => $this->id, + 'type' => 'numeric', + ), + ), + ) ); + + if ( ! empty( $failed_renewal_orders ) ) { + $failed_payment_count += count( $failed_renewal_orders ); + } + + return apply_filters( 'woocommerce_subscription_payment_failed_count', $failed_payment_count, $this ); + } + + /** + * Returns the total amount charged at the outset of the Subscription. + * + * This may return 0 if there is a free trial period or the subscription was synchronised, and no sign up fee, + * otherwise it will be the sum of the sign up fee and price per period. + * + * @return float The total initial amount charged when the subscription product in the order was first purchased, if any. + * @since 2.0 + */ + public function get_total_initial_payment() { + $initial_total = ( false !== $this->order ) ? $this->order->get_total() : 0; + return apply_filters( 'woocommerce_subscription_total_initial_payment', $initial_total, $this ); + } + + /** + * Update the internal tally of suspensions on this subscription since the last payment. + * + * @return int The count of suspensions + * @since 2.0 + */ + public function update_suspension_count( $new_count ) { + $this->suspension_count = $new_count; + update_post_meta( $this->id, '_suspension_count', $this->suspension_count ); + return $this->suspension_count; + } + + /*** Date methods *****************************************************/ + + /** + * Get the MySQL formatted date for a specific piece of the subscriptions schedule + * + * @param string $date_type 'start', 'trial_end', 'next_payment', 'last_payment' or 'end' + * @param string $timezone The timezone of the $datetime param, either 'gmt' or 'site'. Default 'gmt'. + */ + public function get_date( $date_type, $timezone = 'gmt' ) { + + // Accept dates with a '_date' suffix, like 'next_payment_date' or 'start_date' + $date_type = str_replace( '_date', '', $date_type ); + + if ( ! empty( $date_type ) && ! isset( $this->schedule->{$date_type} ) ) { + switch ( $date_type ) { + case 'start' : + $this->schedule->{$date_type} = ( '0000-00-00 00:00:00' != $this->post->post_date_gmt ) ? $this->post->post_date_gmt : get_gmt_from_date( $this->post->post_date ); // why not always use post_date_gmt? Because when a post is first created via the Add Subscription screen, it has a post_date but not a post_date_gmt value yet + break; + case 'next_payment' : + case 'trial_end' : + case 'end' : + $this->schedule->{$date_type} = get_post_meta( $this->id, wcs_get_date_meta_key( $date_type ), true ); + break; + case 'last_payment' : + $this->schedule->{$date_type} = $this->get_last_payment_date(); + break; + default : + $this->schedule->{$date_type} = 0; + break; + } + + if ( empty( $this->schedule->{$date_type} ) || false === $this->schedule->{$date_type} ) { + $this->schedule->{$date_type} = 0; + } + } + + if ( empty( $date_type ) ) { + $date = 0; + } elseif ( 0 != $this->schedule->{$date_type} && 'gmt' != strtolower( $timezone ) ) { + $date = get_date_from_gmt( $this->schedule->{$date_type} ); + } else { + $date = $this->schedule->{$date_type}; + } + + return apply_filters( 'woocommerce_subscription_get_' . $date_type . '_date', $date, $this, $timezone ); + } + + /** + * Returns a string representation of a subscription date in the site's time (i.e. not GMT/UTC timezone). + * + * @param string $date_type 'start', 'trial_end', 'next_payment', 'last_payment', 'end' or 'end_of_prepaid_term' + */ + public function get_date_to_display( $date_type = 'next_payment' ) { + + $date_type = str_replace( '_date', '', $date_type ); + + $timestamp_gmt = $this->get_time( $date_type, 'gmt' ); + + // Don't display next payment date when the subscription is inactive + if ( 'next_payment' == $date_type && ! $this->has_status( 'active' ) ) { + $timestamp_gmt = 0; + } + + if ( $timestamp_gmt > 0 ) { + + $time_diff = $timestamp_gmt - current_time( 'timestamp', true ); + + if ( $time_diff > 0 && $time_diff < WEEK_IN_SECONDS ) { + // translators: placeholder is human time diff (e.g. "3 weeks") + $date_to_display = sprintf( __( 'In %s', 'woocommerce-subscriptions' ), human_time_diff( current_time( 'timestamp', true ), $timestamp_gmt ) ); + } elseif ( $time_diff < 0 && absint( $time_diff ) < WEEK_IN_SECONDS ) { + // translators: placeholder is human time diff (e.g. "3 weeks") + $date_to_display = sprintf( __( '%s ago', 'woocommerce-subscriptions' ), human_time_diff( current_time( 'timestamp', true ), $timestamp_gmt ) ); + } else { + $date_to_display = date_i18n( wc_date_format(), $this->get_time( $date_type, 'site' ) ); + } + } else { + switch ( $date_type ) { + case 'end' : + $date_to_display = __( 'Not yet ended', 'woocommerce-subscriptions' ); + break; + case 'next_payment' : + case 'trial_end' : + default : + $date_to_display = _x( '-', 'original denotes there is no date to display', 'woocommerce-subscriptions' ); + break; + } + } + + return apply_filters( 'woocommerce_subscription_date_to_display', $date_to_display, $date_type, $this ); + } + + /** + * Get the timestamp for a specific piece of the subscriptions schedule + * + * @param string $date_type 'start', 'trial_end', 'next_payment', 'last_payment', 'end' or 'end_of_prepaid_term' + * @param string $timezone The timezone of the $datetime param. Default 'gmt'. + */ + public function get_time( $date_type, $timezone = 'gmt' ) { + + $datetime = $this->get_date( $date_type, $timezone ); + + if ( 0 !== $datetime ) { + $datetime = strtotime( $datetime ); + } + + return $datetime; + } + + /** + * Set the dates on the subscription. + * + * Because dates are interdependent on each other, this function will take an array of dates, make sure that all + * dates are in the right order in the right format, that there is at least something to update. + * + * @param array $dates array containing dates with keys: 'start', 'trial_end', 'next_payment', 'last_payment' or 'end'. Values are time + * @param string $timezone The timezone of the $datetime param. Default 'gmt'. + */ + public function update_dates( $dates, $timezone = 'gmt' ) { + global $wpdb; + + if ( ! is_array( $dates ) ) { + throw new InvalidArgumentException( __( 'Invalid format. First parameter needs to be an array.', 'woocommerce-subscriptions' ) ); + } + + if ( empty( $dates ) ) { + throw new InvalidArgumentException( __( 'Invalid data. First parameter was empty when passed to update_dates().', 'woocommerce-subscriptions' ) ); + } + + $allowed_date_keys = array_keys( wcs_get_subscription_date_types() ); + $passed_date_keys = array_keys( $dates ); + $extra_keys = array_diff( str_replace( '_date', '', $passed_date_keys ), $allowed_date_keys ); + if ( ! empty( $extra_keys ) ) { + throw new InvalidArgumentException( __( 'Invalid data. First parameter has a date that is not in the registered date types.', 'woocommerce-subscriptions' ) ); + } + + $timestamps = array(); + foreach ( $dates as $date_type => $datetime ) { + if ( ! empty( $datetime ) && false === wcs_is_datetime_mysql_format( $datetime ) ) { + // translators: placeholder is date type (e.g. "end", "next_payment"...) + throw new InvalidArgumentException( sprintf( _x( 'Invalid %s date. The date must be of the format: "Y-m-d H:i:s".', 'appears in an error message if date is wrong format', 'woocommerce-subscriptions' ), $date_type ) ); + } + + $date_type = str_replace( '_date', '', $date_type ); + + if ( empty( $datetime ) ) { + + $timestamps[ $date_type ] = 0; + + } else { + + if ( 'gmt' !== strtolower( $timezone ) ) { + $datetime = get_gmt_from_date( $datetime ); + } + + $timestamps[ $date_type ] = strtotime( $datetime ); + } + } + + foreach ( $allowed_date_keys as $date_type ) { + if ( ! array_key_exists( $date_type, $timestamps ) ) { + $timestamps[ $date_type ] = $this->get_time( $date_type ); + } + + if ( 0 == $timestamps[ $date_type ] ) { + // Last payment is not in the UI, and it should NOT be deleted as that would mess with scheduling + if ( 'last_payment' != $date_type && 'start' != $date_type ) { + $this->delete_date( $date_type ); + } + unset( $timestamps[ $date_type ] ); + continue; + } + } + + $messages = array(); + + // And then iterate over them. We need the two separate loops as we need a full array before we start checking the relationships between them. + foreach ( $timestamps as $date_type => $datetime ) { + switch ( $date_type ) { + case 'end' : + if ( array_key_exists( 'last_payment', $timestamps ) && $datetime < $timestamps['last_payment'] ) { + $messages[] = sprintf( __( 'The %s date must occur after the last payment date.', 'woocommerce-subscriptions' ), $date_type ); + } + + if ( array_key_exists( 'next_payment', $timestamps ) && $datetime <= $timestamps['next_payment'] ) { + $messages[] = sprintf( __( 'The %s date must occur after the next payment date.', 'woocommerce-subscriptions' ), $date_type ); + } + case 'next_payment' : + // Guarantees that end is strictly after trial_end, because if next_payment and end can't be at same time + if ( array_key_exists( 'trial_end', $timestamps ) && $datetime < $timestamps['trial_end'] ) { + $messages[] = sprintf( __( 'The %s date must occur after the trial end date.', 'woocommerce-subscriptions' ), $date_type ); + } + case 'trial_end' : + if ( $datetime <= $timestamps['start'] ) { + $messages[] = sprintf( __( 'The %s date must occur after the start date.', 'woocommerce-subscriptions' ), $date_type ); + } + } + } + + if ( ! empty( $messages ) ) { + throw new Exception( join( ' ', $messages ) ); + } + + $is_updated = false; + + foreach ( $timestamps as $date_type => $timestamp ) { + $datetime = date( 'Y-m-d H:i:s', $timestamp ); + + if ( $datetime == $this->get_date( $date_type ) ) { + continue; + } + + switch ( $date_type ) { + case 'next_payment' : + case 'trial_end' : + case 'end' : + $is_updated = update_post_meta( $this->id, wcs_get_date_meta_key( $date_type ), $datetime ); + break; + case 'start' : + $wpdb->query( $wpdb->prepare( "UPDATE $wpdb->posts SET post_date = %s, post_date_gmt = %s WHERE ID = %s", get_date_from_gmt( $datetime ), $datetime, $this->id ) ); // Don't use wp_update_post() to avoid infinite loops here + $is_updated = true; + break; + case 'last_payment' : + $this->update_last_payment_date( $datetime ); + $is_updated = true; + break; + } + + if ( $is_updated ) { + $this->schedule->{$date_type} = $datetime; + do_action( 'woocommerce_subscription_date_updated', $this, $date_type, $datetime ); + } + } + } + + /** + * Remove a date from a subscription. + * + * @param string $date_type 'trial_end', 'next_payment' or 'end'. The 'start' and 'last_payment' date types will throw an exception. + */ + public function delete_date( $date_type ) { + + // Accept dates with a '_date' suffix, like 'next_payment_date' or 'start_date' + $date_type = str_replace( '_date', '', $date_type ); + + // Make sure some dates are before next payment date + if ( in_array( $date_type, array( 'start', 'last_payment' ) ) ) { + switch ( $date_type ) { + case 'start' : + $message = __( 'The start date of a subscription can not be deleted, only updated.', 'woocommerce-subscriptions' ); + break; + case 'last_payment' : + $message = __( 'The last payment date of a subscription can not be deleted. You must delete the order.', 'woocommerce-subscriptions' ); + break; + } + throw new Exception( $message ); + } + + $this->schedule->{$date_type} = 0; + update_post_meta( $this->id, wcs_get_date_meta_key( $date_type ), $this->schedule->{$date_type} ); + do_action( 'woocommerce_subscription_date_deleted', $this, $date_type ); + } + + /** + * Check if a given date type can be updated for this subscription. + * + * @param string $date_type 'start', 'trial_end', 'next_payment', 'last_payment' or 'end' + */ + public function can_date_be_updated( $date_type ) { + + switch ( $date_type ) { + case 'start' : + if ( $this->has_status( array( 'auto-draft', 'pending' ) ) ) { + $can_date_be_updated = true; + } else { + $can_date_be_updated = false; + } + break; + case 'trial_end' : + if ( $this->get_completed_payment_count() < 2 && ! $this->has_status( wcs_get_subscription_ended_statuses() ) && ( $this->has_status( 'pending' ) || $this->payment_method_supports( 'subscription_date_changes' ) ) ) { + $can_date_be_updated = true; + } else { + $can_date_be_updated = false; + } + break; + case 'next_payment' : + case 'end' : + if ( ! $this->has_status( wcs_get_subscription_ended_statuses() ) && ( $this->has_status( 'pending' ) || $this->payment_method_supports( 'subscription_date_changes' ) ) ) { + $can_date_be_updated = true; + } else { + $can_date_be_updated = false; + } + break; + case 'last_payment' : + $can_date_be_updated = true; + break; + default : + $can_date_be_updated = false; + break; + } + + return apply_filters( 'woocommerce_subscription_can_date_be_updated', $can_date_be_updated, $date_type, $this ); + } + + /** + * Calculate a given date for the subscription in GMT/UTC. + * + * @param string $date_type 'trial_end', 'next_payment', 'end_of_prepaid_term' or 'end' + */ + public function calculate_date( $date_type ) { + + switch ( $date_type ) { + case 'next_payment' : + $date = $this->calculate_next_payment_date(); + break; + case 'trial_end' : + if ( $this->get_completed_payment_count() >= 2 ) { + $date = 0; + } else { + // By default, trial end is the same as the next payment date + $date = $this->calculate_next_payment_date(); + } + break; + case 'end_of_prepaid_term' : + + $next_payment_time = $this->get_time( 'next_payment' ); + $end_time = $this->get_time( 'end' ); + + // If there was a future payment, the customer has paid up until that payment date + if ( $this->get_time( 'next_payment' ) >= current_time( 'timestamp', true ) ) { + $date = $this->get_date( 'next_payment' ); + // If there is no future payment and no expiration date set, the customer has no prepaid term (this shouldn't be possible as only active subscriptions can be set to pending cancellation and an active subscription always has either an end date or next payment) + } elseif ( 0 == $next_payment_time || $end_time <= current_time( 'timestamp', true ) ) { + $date = current_time( 'mysql', true ); + } else { + $date = $this->get_date( 'end' ); + } + break; + default : + $date = 0; + break; + } + + return apply_filters( 'woocommerce_subscription_calculated_' . $date_type . '_date', $date, $this ); + } + + /** + * Calculates the next payment date for a subscription. + * + * Although an inactive subscription does not have a next payment date, this function will still calculate the date + * so that it can be used to determine the date the next payment should be charged for inactive subscriptions. + * + * @return int | string Zero if the subscription has no next payment date, or a MySQL formatted date time if there is a next payment date + */ + protected function calculate_next_payment_date() { + + $next_payment_date = 0; + + // If the subscription is not active, there is no next payment date + $start_time = $this->get_time( 'start' ); + $next_payment_time = $this->get_time( 'next_payment' ); + $trial_end_time = $this->get_time( 'trial_end' ); + $last_payment_time = $this->get_time( 'last_payment' ); + $end_time = $this->get_time( 'end' ); + + // If the subscription has a free trial period, and we're still in the free trial period, the next payment is due at the end of the free trial + if ( $trial_end_time > current_time( 'timestamp', true ) ) { + + $next_payment_timestamp = $trial_end_time; + + } else { + + // The next payment date is {interval} billing periods from the start date, trial end date or last payment date + if ( 0 !== $next_payment_time && $next_payment_time < gmdate( 'U' ) && ( ( 0 !== $trial_end_time && 1 >= $this->get_completed_payment_count() ) || WC_Subscriptions_Synchroniser::subscription_contains_synced_product( $this ) ) ) { + $from_timestamp = $next_payment_time; + } elseif ( $last_payment_time > $start_time && apply_filters( 'wcs_calculate_next_payment_from_last_payment', true, $this ) ) { + $from_timestamp = $last_payment_time; + } elseif ( $next_payment_time > $start_time ) { // Use the currently scheduled next payment to preserve synchronisation + $from_timestamp = $next_payment_time; + } else { + $from_timestamp = $start_time; + } + + $next_payment_timestamp = wcs_add_time( $this->billing_interval, $this->billing_period, $from_timestamp ); + + // Make sure the next payment is more than 2 hours in the future, this ensures changes to the site's timezone because of daylight savings will never cause a 2nd renewal payment to be processed on the same day + $i = 1; + while ( $next_payment_timestamp < ( current_time( 'timestamp', true ) + 2 * HOUR_IN_SECONDS ) && $i < 30 ) { + $next_payment_timestamp = wcs_add_time( $this->billing_interval, $this->billing_period, $next_payment_timestamp ); + $i += 1; + } + } + + // If the subscription has an end date and the next billing period comes after that, return 0 + if ( 0 != $end_time && ( $next_payment_timestamp + 120 ) > $end_time ) { + $next_payment_timestamp = 0; + } + + if ( $next_payment_timestamp > 0 ) { + $next_payment_date = date( 'Y-m-d H:i:s', $next_payment_timestamp ); + } + + return $next_payment_date; + } + + /** + * Get the last payment date for a subscription, in GMT/UTC. + * + * The last payment date is based on the original order used to purchase the subscription or + * it's last paid renewal order, which ever is more recent. + * + * @since 2.0 + */ + protected function get_last_payment_date() { + $last_order = $this->get_last_order( 'all' ); + + if ( ! $last_order ) { + return 0; + } + + return $last_order->post->post_date_gmt; + } + + /** + * + * @param string $datetime A MySQL formatted date/time string in GMT/UTC timezone. + */ + protected function update_last_payment_date( $datetime ) { + $last_order = $this->get_last_order(); + + if ( ! $last_order ) { + return false; + } + + $updated_post_data = array( + 'ID' => $last_order, + 'post_date' => get_date_from_gmt( $datetime ), + 'post_date_gmt' => $datetime, + ); + + wp_update_post( $updated_post_data ); + + return $datetime; + } + + + /** Formatted Totals Methods *******************************************************/ + + /** + * Gets line subtotal - formatted for display. + * + * @param array $item + * @param string $tax_display + * @return string + */ + public function get_formatted_line_subtotal( $item, $tax_display = '' ) { + + if ( ! $tax_display ) { + $tax_display = $this->tax_display_cart; + } + + if ( ! isset( $item['line_subtotal'] ) || ! isset( $item['line_subtotal_tax'] ) ) { + return ''; + } + + if ( 'excl' == $tax_display ) { + $display_ex_tax_label = $this->prices_include_tax ? 1 : 0; + $subtotal = wcs_price_string( $this->get_price_string_details( $this->get_line_subtotal( $item ) ), $display_ex_tax_label ); + } else { + $subtotal = wcs_price_string( $this->get_price_string_details( $this->get_line_subtotal( $item, true ) ) ); + } + + return apply_filters( 'woocommerce_order_formatted_line_subtotal', $subtotal, $item, $this ); + } + + /** + * Gets order total - formatted for display. + * + * @param string $tax_display only used for method signature match + * @param bool $display_refunded only used for method signature match + * @return string + */ + public function get_formatted_order_total( $tax_display = '', $display_refunded = true ) { + if ( $this->get_total() > 0 && ! empty( $this->billing_period ) ) { + $formatted_order_total = wcs_price_string( $this->get_price_string_details( $this->get_total() ) ); + } else { + $formatted_order_total = parent::get_formatted_order_total(); + } + return apply_filters( 'woocommerce_get_formatted_subscription_total', $formatted_order_total, $this ); + } + + /** + * Gets subtotal - subtotal is shown before discounts, but with localised taxes. + * + * @param bool $compound (default: false) + * @param string $tax_display (default: the tax_display_cart value) + * @return string + */ + public function get_subtotal_to_display( $compound = false, $tax_display = '' ) { + + if ( ! $tax_display ) { + $tax_display = $this->tax_display_cart; + } + + $subtotal = 0; + + if ( ! $compound ) { + foreach ( $this->get_items() as $item ) { + + if ( ! isset( $item['line_subtotal'] ) || ! isset( $item['line_subtotal_tax'] ) ) { + return ''; + } + + $subtotal += $item['line_subtotal']; + + if ( 'incl' == $tax_display ) { + $subtotal += $item['line_subtotal_tax']; + } + } + + $subtotal = wc_price( $subtotal, array( 'currency' => $this->get_order_currency() ) ); + + if ( 'excl' == $tax_display && $this->prices_include_tax ) { + $subtotal .= ' ' . WC()->countries->ex_tax_or_vat() . ''; + } + } else { + + if ( 'incl' == $tax_display ) { + return ''; + } + + foreach ( $this->get_items() as $item ) { + + $subtotal += $item['line_subtotal']; + + } + + // Add Shipping Costs + $subtotal += $this->get_total_shipping(); + + // Remove non-compound taxes + foreach ( $this->get_taxes() as $tax ) { + + if ( ! empty( $tax['compound'] ) ) { + continue; + } + + $subtotal = $subtotal + $tax['tax_amount'] + $tax['shipping_tax_amount']; + + } + + // Remove discounts + $subtotal = $subtotal - $this->get_cart_discount(); + + $subtotal = wc_price( $subtotal, array( 'currency' => $this->get_order_currency() ) ); + } + + return apply_filters( 'woocommerce_order_subtotal_to_display', $subtotal, $compound, $this ); + } + + /** + * Get the details of the subscription for use with @see wcs_price_string() + * + * This is protected because it should not be used directly by outside methods. If you need + * to display the price of a subscription, use the @see $this->get_formatted_order_total(), + * @see $this->get_subtotal_to_display() or @see $this->get_formatted_line_subtotal() method. + * If you want to customise which aspects of a price string are displayed for all subscriptions, + * use the filter 'woocommerce_subscription_price_string_details'. + * + * @return array + */ + protected function get_price_string_details( $amount = 0, $display_ex_tax_label = false ) { + + $subscription_details = array( + 'currency' => $this->get_order_currency(), + 'recurring_amount' => $amount, + 'subscription_period' => $this->billing_period, + 'subscription_interval' => $this->billing_interval, + 'display_ex_tax_label' => $display_ex_tax_label, + ); + + return apply_filters( 'woocommerce_subscription_price_string_details', $subscription_details, $this ); + } + + /** + * Cancel the order and restore the cart (before payment) + * + * @param string $note (default: '') Optional note to add + */ + public function cancel_order( $note = '' ) { + + // If the customer hasn't been through the pending cancellation period yet set the subscription to be pending cancellation + if ( $this->has_status( 'active' ) && $this->calculate_date( 'end_of_prepaid_term' ) > current_time( 'mysql', true ) && apply_filters( 'woocommerce_subscription_use_pending_cancel', true ) ) { + + $this->update_status( 'pending-cancel', $note ); + + // If the subscription has already ended or can't be cancelled for some other reason, just record the note + } elseif ( ! $this->can_be_updated_to( 'cancelled' ) ) { + + $this->add_order_note( $note ); + + // Cancel for real if we're already pending cancellation + } else { + + $this->update_status( 'cancelled', $note ); + + } + } + + /** + * Allow subscription amounts/items to bed edited if the gateway supports it. + * + * @access public + * @return bool + */ + public function is_editable() { + + if ( ! isset( $this->editable ) ) { + + if ( $this->has_status( array( 'pending', 'draft', 'auto-draft' ) ) ) { + $this->editable = true; + } elseif ( $this->is_manual() || $this->payment_method_supports( 'subscription_amount_changes' ) ) { + $this->editable = true; + } else { + $this->editable = false; + } + } + + return apply_filters( 'wc_order_is_editable', $this->editable, $this ); + } + + /** + * When payment is completed, either for the original purchase or a renewal payment, this function processes it. + * + * @param $transaction_id string Optional transaction id to store in post meta + */ + public function payment_complete( $transaction_id = '' ) { + + // Make sure the last order's status is updated + $last_order = $this->get_last_order( 'all', 'any' ); + + if ( false !== $last_order && $last_order->needs_payment() ) { + $last_order->payment_complete( $transaction_id ); + } + + // Reset suspension count + $this->update_suspension_count( 0 ); + + // Make sure subscriber has default role + wcs_update_users_role( $this->get_user_id(), 'default_subscriber_role' ); + + // Add order note depending on initial payment + if ( 0 == $this->get_total_initial_payment() && 1 == $this->get_completed_payment_count() && false !== $this->order ) { + $note = __( 'Sign-up complete.', 'woocommerce-subscriptions' ); + } else { + $note = __( 'Payment received.', 'woocommerce-subscriptions' ); + } + + $this->add_order_note( $note ); + + $this->update_status( 'active' ); + + do_action( 'woocommerce_subscription_payment_complete', $this ); + + if ( false !== $last_order && wcs_order_contains_renewal( $last_order ) ) { + do_action( 'woocommerce_subscription_renewal_payment_complete', $this ); + } + } + + /** + * When a payment fails, either for the original purchase or a renewal payment, this function processes it. + * + * @since 2.0 + */ + public function payment_failed( $new_status = 'on-hold' ) { + + // Make sure the last order's status is set to failed + $last_order = $this->get_last_order( 'all', 'any' ); + + if ( false !== $last_order && false === $last_order->has_status( 'failed' ) ) { + remove_filter( 'woocommerce_order_status_changed', 'WC_Subscriptions_Renewal_Order::maybe_record_subscription_payment' ); + $last_order->update_status( 'failed' ); + add_filter( 'woocommerce_order_status_changed', 'WC_Subscriptions_Renewal_Order::maybe_record_subscription_payment', 10, 3 ); + } + + // Log payment failure on order + $this->add_order_note( __( 'Payment failed.', 'woocommerce-subscriptions' ) ); + + // Allow a short circuit for plugins & payment gateways to force max failed payments exceeded + if ( 'cancelled' == $new_status || apply_filters( 'woocommerce_subscription_max_failed_payments_exceeded', false, $this ) ) { + $this->update_status( 'cancelled', __( 'Subscription Cancelled: maximum number of failed payments reached.', 'woocommerce-subscriptions' ) ); + } else { + $this->update_status( $new_status ); + } + + do_action( 'woocommerce_subscription_payment_failed', $this, $new_status ); + + if ( false !== $last_order && wcs_order_contains_renewal( $last_order ) ) { + do_action( 'woocommerce_subscription_renewal_payment_failed', $this ); + } + } + + /*** Refund related functions are required for the Edit Order/Subscription screen, but they aren't used on a subscription ************/ + + /** + * Get order refunds + * + * @since 2.2 + * @return array + */ + public function get_refunds() { + if ( ! is_array( $this->refunds ) ) { + $this->refunds = array(); + } + return $this->refunds; + } + + /** + * Get amount already refunded + * + * @since 2.2 + * @return int|float + */ + public function get_total_refunded() { + return 0; + } + + /** + * Get the refunded amount for a line item + * + * @param int $item_id ID of the item we're checking + * @param string $item_type type of the item we're checking, if not a line_item + * @return integer + */ + public function get_qty_refunded_for_item( $item_id, $item_type = 'line_item' ) { + return 0; + } + + /** + * Get the refunded amount for a line item + * + * @param int $item_id ID of the item we're checking + * @param string $item_type type of the item we're checking, if not a line_item + * @return integer + */ + public function get_total_refunded_for_item( $item_id, $item_type = 'line_item' ) { + return 0; + } + + /** + * Get the refunded amount for a line item + * + * @param int $item_id ID of the item we're checking + * @param int $tax_id ID of the tax we're checking + * @param string $item_type type of the item we're checking, if not a line_item + * @return integer + */ + public function get_tax_refunded_for_item( $item_id, $tax_id, $item_type = 'line_item' ) { + return 0; + } + + /** + * Extracting the query from get_related_orders and get_last_order so it can be moved in a cached + * value. + * + * @return array + */ + public function get_related_orders_query( $id ) { + $related_post_ids = get_posts( array( + 'posts_per_page' => -1, + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => '=', + 'value' => $id, + 'type' => 'numeric', + ), + ), + ) ); + + return $related_post_ids; + } + + /** + * Get the related orders for a subscription, including renewal orders and the initial order (if any) + * + * @param string $return_fields The columns to return, either 'all' or 'ids' + * @param string $order_type The type of orders to return, either 'renewal' or 'all'. Default 'all'. + * @since 2.0 + */ + public function get_related_orders( $return_fields = 'ids', $order_type = 'all' ) { + + $return_fields = ( 'ids' == $return_fields ) ? $return_fields : 'all'; + + $related_orders = array(); + + $related_post_ids = WC_Subscriptions::$cache->cache_and_get( 'wcs-related-orders-to-' . $this->id, array( $this, 'get_related_orders_query' ), array( $this->id ) ); + + if ( 'all' == $return_fields ) { + + foreach ( $related_post_ids as $post_id ) { + $related_orders[ $post_id ] = wc_get_order( $post_id ); + } + + if ( false !== $this->order && 'renewal' !== $order_type ) { + $related_orders[ $this->order->id ] = $this->order; + } + } else { + + // Return IDs only + if ( isset( $this->order->id ) && 'renewal' !== $order_type ) { + $related_orders[ $this->order->id ] = $this->order->id; + } + + foreach ( $related_post_ids as $post_id ) { + $related_orders[ $post_id ] = $post_id; + } + } + + return apply_filters( 'woocommerce_subscription_related_orders', $related_orders, $this, $return_fields, $order_type ); + } + + + /** + * Gets the most recent order that relates to a subscription, including renewal orders and the initial order (if any). + * + * @param string $return_fields The columns to return, either 'all' or 'ids' + * @param array $order_types Can include any combination of 'parent', 'renewal', 'switch' or 'any' which will return the latest renewal order of any type. Defaults to 'parent' and 'renewal'. + * @since 2.0 + */ + public function get_last_order( $return_fields = 'ids', $order_types = array( 'parent', 'renewal' ) ) { + + $return_fields = ( 'ids' == $return_fields ) ? $return_fields : 'all'; + $order_types = ( 'any' == $order_types ) ? array( 'parent', 'renewal', 'switch' ) : $order_types; + $related_orders = array(); + + foreach ( $order_types as $order_type ) { + switch ( $order_type ) { + case 'parent': + if ( false !== $this->order ) { + $related_orders[] = $this->order->id; + } + break; + case 'renewal': + $related_orders = array_merge( $related_orders, WC_Subscriptions::$cache->cache_and_get( 'wcs-related-orders-to-' . $this->id, array( $this, 'get_related_orders_query' ), array( $this->id ) ) ); + break; + case 'switch': + $related_orders = array_merge( $related_orders, array_keys( wcs_get_switch_orders_for_subscription( $this->id ) ) ); + break; + default: + break; + } + } + + if ( empty( $related_orders ) ) { + $last_order = false; + } else { + $last_order = max( $related_orders ); + + if ( 'all' == $return_fields ) { + if ( false !== $this->order && $last_order == $this->order->id ) { + $last_order = $this->order; + } else { + $last_order = wc_get_order( $last_order ); + } + } + } + + return apply_filters( 'woocommerce_subscription_last_order', $last_order, $this ); + } + + /** + * Determine how the payment method should be displayed for a subscription. + * + * @since 2.0 + */ + public function get_payment_method_to_display() { + + if ( $this->is_manual() ) { + + $payment_method_to_display = __( 'Manual Renewal', 'woocommerce-subscriptions' ); + + // Use the current title of the payment gateway when available + } elseif ( false !== $this->payment_gateway ) { + + $payment_method_to_display = $this->payment_gateway->get_title(); + + // Fallback to the title of the payment method when the subscripion was created + } else { + + $payment_method_to_display = $this->payment_method_title; + + } + + return apply_filters( 'woocommerce_subscription_payment_method_to_display', $payment_method_to_display, $this ); + } + + /** + * Save new payment method for a subscription + * + * @since 2.0 + * @param WC_Payment_Gateway|empty $payment_method + * @param array $payment_meta Associated array of the form: $database_table => array( value, ) + */ + public function set_payment_method( $payment_gateway = '', $payment_meta = array() ) { + + if ( ! empty( $payment_meta ) && isset( $payment_gateway->id ) ) { + $this->set_payment_method_meta( $payment_gateway->id, $payment_meta ); + } + + if ( empty( $payment_gateway ) || ! isset( $payment_gateway->id ) ) { + + $this->update_manual( true ); + update_post_meta( $this->id, '_payment_method', '' ); + update_post_meta( $this->id, '_payment_method_title', '' ); + + } elseif ( $this->payment_method !== $payment_gateway->id ) { + + // Set subscription to manual when the payment method doesn't support automatic payments + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + + if ( 'yes' == get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) ) { + $this->update_manual( true ); + } elseif ( ! isset( $available_gateways[ $payment_gateway->id ] ) || ! $available_gateways[ $payment_gateway->id ]->supports( 'subscriptions' ) ) { + $this->update_manual( true ); + } else { + $this->update_manual( false ); + } + + update_post_meta( $this->id, '_payment_method', $payment_gateway->id ); + update_post_meta( $this->id, '_payment_method_title', $payment_gateway->get_title() ); + } + + $this->payment_gateway = wc_get_payment_gateway_by_order( $this ); + } + + /** + * Save payment method meta data for the Subscription + * + * @since 2.0 + * @param array $payment_meta Associated array of the form: $database_table => array( value, ) + */ + protected function set_payment_method_meta( $payment_method_id, $payment_meta ) { + + if ( ! is_array( $payment_meta ) ) { + throw new InvalidArgumentException( __( 'Payment method meta must be an array.', 'woocommerce-subscriptions' ) ); + } + + // Allow payment gateway extensions to validate the data and throw exceptions if necessary + do_action( 'woocommerce_subscription_validate_payment_meta', $payment_method_id, $payment_meta, $this ); + do_action( 'woocommerce_subscription_validate_payment_meta_' . $payment_method_id, $payment_meta, $this ); + + foreach ( $payment_meta as $meta_table => $meta ) { + foreach ( $meta as $meta_key => $meta_data ) { + if ( isset( $meta_data['value'] ) ) { + switch ( $meta_table ) { + case 'user_meta': + case 'usermeta': + update_user_meta( $this->customer_user, $meta_key, $meta_data['value'] ); + break; + case 'post_meta': + case 'postmeta': + update_post_meta( $this->id, $meta_key, $meta_data['value'] ); + break; + case 'options': + update_option( $meta_key, $meta_data['value'] ); + break; + default: + do_action( 'wcs_save_other_payment_meta', $this, $meta_table, $meta_key, $meta_data['value'] ); + } + } + } + } + + } + + /** + * Now uses the URL /my-account/view-subscription/{post-id} when viewing a subscription from the My Account Page. + * + * @since 2.0 + */ + public function get_view_order_url() { + $view_subscription_url = wc_get_endpoint_url( 'view-subscription', $this->id, wc_get_page_permalink( 'myaccount' ) ); + + return apply_filters( 'wcs_get_view_subscription_url', $view_subscription_url, $this->id ); + } + + /** + * Checks if product download is permitted + * + * @return bool + */ + public function is_download_permitted() { + return apply_filters( 'woocommerce_order_is_download_permitted', ( $this->has_status( 'active' ) || $this->has_status( 'pending-cancel' ) ), $this ); + } + + /** + * Check if the subscription has a line item for a specific product, by ID. + * + * @param int A product or variation ID to check for. + * @return bool + */ + public function has_product( $product_id ) { + + $has_product = false; + + foreach ( $this->get_items() as $line_item ) { + if ( $line_item['product_id'] == $product_id || $line_item['variation_id'] == $product_id ) { + $has_product = true; + break; + } + } + + return $has_product; + } + + /** + * The total sign-up fee for the subscription if any. + * + * @param array|int Either an order item (in the array format returned by self::get_items()) or the ID of an order item. + * @return bool + * @since 2.0 + */ + public function get_sign_up_fee() { + + $sign_up_fee = 0; + + foreach ( $this->get_items() as $line_item ) { + try { + $sign_up_fee += $this->get_items_sign_up_fee( $line_item ); + } catch ( Exception $e ) { + $sign_up_fee += 0; + } + } + + return apply_filters( 'woocommerce_subscription_sign_up_fee', $sign_up_fee, $this ); + } + + /** + * Check if a given line item on the subscription had a sign-up fee, and if so, return the value of the sign-up fee. + * + * The single quantity sign-up fee will be returned instead of the total sign-up fee paid. For example, if 3 x a product + * with a 10 BTC sign-up fee was purchased, a total 30 BTC was paid as the sign-up fee but this function will return 10 BTC. + * + * @param array|int Either an order item (in the array format returned by self::get_items()) or the ID of an order item. + * @param string $tax Whether or not to adjust sign up fee if prices inc tax - ensures that the sign up fee paid amount includes the paid tax if inc + * @return bool + * @since 2.0 + */ + public function get_items_sign_up_fee( $line_item, $tax = 'exclusive_of_tax' ) { + + if ( ! is_array( $line_item ) ) { + $line_item = wcs_get_order_item( $line_item, $this ); + } + + // If there was no original order, nothing was paid up-front which means no sign-up fee + if ( empty( $this->order ) ) { + + $sign_up_fee = 0; + + } else { + + $original_order_item = ''; + + // Find the matching item on the order + foreach ( $this->order->get_items() as $order_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == wcs_get_canonical_product_id( $order_item ) ) { + $original_order_item = $order_item; + break; + } + } + + // No matching order item, so this item wasn't purchased in the original order + if ( empty( $original_order_item ) ) { + + $sign_up_fee = 0; + + } elseif ( isset( $line_item['item_meta']['_has_trial'] ) ) { + + // Sign up was was total amount paid for this item on original order + $sign_up_fee = $original_order_item['line_total'] / $original_order_item['qty']; + + } else { + + // Sign-up fee is any amount on top of recurring amount + $sign_up_fee = max( $original_order_item['line_total'] / $original_order_item['qty'] - $line_item['line_total'] / $line_item['qty'], 0 ); + } + + // If prices inc tax, ensure that the sign up fee amount includes the tax + if ( 'inclusive_of_tax' === $tax && ! empty( $original_order_item ) && ( 'yes' == $this->prices_include_tax || true === $this->prices_include_tax ) ) { + $sign_up_fee += $original_order_item['line_tax']; + } + } + + return apply_filters( 'woocommerce_subscription_items_sign_up_fee', $sign_up_fee, $line_item, $this ); + } + + /** + * Get the downloadable files for an item in this subscription if the subscription is active + * + * @param array $item + * @return array + */ + public function get_item_downloads( $item ) { + global $wpdb; + + $files = array(); + + // WC Emails are sent before the subscription status is updated to active etc. so we need a way to ensure download links are added to the emails before being sent + $sending_email = ( did_action( 'woocommerce_email_before_order_table' ) > did_action( 'woocommerce_email_after_order_table' ) ) ? true : false; + + if ( $this->has_status( apply_filters( 'woocommerce_subscription_item_download_statuses', array( 'active', 'pending-cancel' ) ) ) || $sending_email ) { + $files = parent::get_item_downloads( $item ); + } + + return apply_filters( 'woocommerce_get_item_downloads', $files, $item, $this ); + } +} diff --git a/includes/class-wc-subscriptions-addresses.php b/includes/class-wc-subscriptions-addresses.php new file mode 100644 index 0000000..0f6b518 --- /dev/null +++ b/includes/class-wc-subscriptions-addresses.php @@ -0,0 +1,176 @@ + $actions array with all actions that will be displayed for a subscription on the "My Subscriptions" table + * @param array $subscriptions All of a given users subscriptions that will be displayed on the "My Subscriptions" table + * @since 1.3 + */ + public static function add_edit_address_subscription_action( $actions, $subscription ) { + + if ( $subscription->needs_shipping_address() && $subscription->has_status( array( 'active', 'on-hold' ) ) ) { + $actions['change_address'] = array( + 'url' => add_query_arg( array( 'subscription' => $subscription->id ), wc_get_endpoint_url( 'edit-address', 'shipping' ) ), + 'name' => __( 'Change Address', 'woocommerce-subscriptions' ), + ); + } + + return $actions; + } + + /** + * Outputs the necessary markup on the "My Account" > "Edit Address" page for editing a single subscription's + * address or to check if the customer wants to update the addresses for all of their subscriptions. + * + * If editing their default shipping address, this function adds a checkbox to the to allow subscribers to + * also update the address on their active subscriptions. If editing a single subscription's address, the + * subscription key is added as a hidden field. + * + * @since 1.3 + */ + public static function maybe_add_edit_address_checkbox() { + global $wp; + + if ( wcs_user_has_subscription() ) { + + if ( isset( $_GET['subscription'] ) ) { + + echo '

    ' . esc_html__( 'Both the shipping address used for the subscription and your default shipping address for future purchases will be updated.', 'woocommerce-subscriptions' ) . '

    '; + + echo ''; + + } elseif ( ( ( isset( $wp->query_vars['edit-address'] ) && ! empty( $wp->query_vars['edit-address'] ) ) || isset( $_GET['address'] ) ) ) { + + if ( isset( $wp->query_vars['edit-address'] ) ) { + $address_type = esc_attr( $wp->query_vars['edit-address'] ) . ' '; + } else { + $address_type = ( ! isset( $_GET['address'] ) ) ? esc_attr( $_GET['address'] ) . ' ' : ''; + } + + // translators: $1: address type (Shipping Address / Billing Address), $2: opening tag, $3: closing tag + $label = sprintf( __( 'Update the %1$s used for %2$sall%3$s of my active subscriptions', 'woocommerce-subscriptions' ), wcs_get_address_type_to_display( $address_type ), '', '' ); + + woocommerce_form_field( 'update_all_subscriptions_addresses', array( + 'type' => 'checkbox', + 'class' => array( 'form-row-wide' ), + 'label' => $label, + ) + ); + } + + wp_nonce_field( 'wcs_edit_address', '_wcsnonce' ); + + } + } + + /** + * When a subscriber's billing or shipping address is successfully updated, check if the subscriber + * has also requested to update the addresses on existing subscriptions and if so, go ahead and update + * the addresses on the initial order for each subscription. + * + * @param int $user_id The ID of a user who own's the subscription (and address) + * @since 1.3 + */ + public static function maybe_update_subscription_addresses( $user_id, $address_type ) { + + if ( ! wcs_user_has_subscription( $user_id ) || wc_notice_count( 'error' ) > 0 || empty( $_POST['_wcsnonce'] ) || ! wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_edit_address' ) ) { + return; + } + + $address_type = ( 'billing' == $address_type || 'shipping' == $address_type ) ? $address_type : ''; + $address_fields = WC()->countries->get_address_fields( esc_attr( $_POST[ $address_type . '_country' ] ), $address_type . '_' ); + $address = array(); + + foreach ( $address_fields as $key => $field ) { + if ( isset( $_POST[ $key ] ) ) { + $address[ str_replace( $address_type . '_', '', $key ) ] = wc_clean( $_POST[ $key ] ); + } + } + + if ( isset( $_POST['update_all_subscriptions_addresses'] ) ) { + + $users_subscriptions = wcs_get_users_subscriptions( $user_id ); + + foreach ( $users_subscriptions as $subscription ) { + if ( $subscription->has_status( array( 'active', 'on-hold' ) ) ) { + $subscription->set_address( $address, $address_type ); + } + } + } elseif ( isset( $_POST['update_subscription_address'] ) ) { + + $subscription = wcs_get_subscription( intval( $_POST['update_subscription_address'] ) ); + + // Update the address only if the user actually owns the subscription + if ( ! empty( $subscription ) ) { + $subscription->set_address( $address, $address_type ); + } + + wp_safe_redirect( $subscription->get_view_order_url() ); + exit(); + } + } + + /** + * Prepopulate the address fields on a subscription item + * + * @param array $address A WooCommerce address array + * @since 1.5 + */ + public static function maybe_populate_subscription_addresses( $address ) { + + if ( isset( $_GET['subscription'] ) ) { + $subscription = wcs_get_subscription( absint( $_GET['subscription'] ) ); + + foreach ( array_keys( $address ) as $key ) { + $address[ $key ]['value'] = $subscription->$key; + } + } + + return $address; + } + + /** + * Update the address fields on an order + * + * @param array $subscription A WooCommerce Subscription array + * @param array $address_fields Locale aware address fields of the form returned by WC_Countries->get_address_fields() for a given country + * @since 1.3 + */ + public static function maybe_update_order_address( $subscription, $address_fields ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Order::set_address() or WC_Subscription::set_address()' ); + } +} + +WC_Subscriptions_Addresses::init(); diff --git a/includes/class-wc-subscriptions-cart.php b/includes/class-wc-subscriptions-cart.php new file mode 100644 index 0000000..e04c42a --- /dev/null +++ b/includes/class-wc-subscriptions-cart.php @@ -0,0 +1,2153 @@ +cart->recurring_carts = array(); + + // Only hook when cart contains a subscription + if ( ! self::cart_contains_subscription() ) { + return; + } + + // Set which price should be used for calculation + add_filter( 'woocommerce_get_price', __CLASS__ . '::set_subscription_prices_for_calculation', 100, 2 ); + } + + /** + * Removes the "set_subscription_prices_for_calculation" filter from the WC Product's woocommerce_get_price hook once + * calculations are complete. + * + * @since 1.2 + */ + public static function remove_calculation_price_filter() { + remove_filter( 'woocommerce_get_price', __CLASS__ . '::set_subscription_prices_for_calculation', 100, 2 ); + } + + /** + * If we are running a custom calculation, we need to set the price returned by a product + * to be the appropriate value. This may include just the sign-up fee, a combination of the + * sign-up fee and recurring amount or just the recurring amount (default). + * + * If there are subscriptions in the cart and the product is not a subscription, then + * set the recurring total to 0. + * + * @since 1.2 + */ + public static function set_subscription_prices_for_calculation( $price, $product ) { + + if ( WC_Subscriptions_Product::is_subscription( $product ) ) { + + // For original calculations, we need the items price to account for sign-up fees and/or free trial + if ( 'none' == self::$calculation_type ) { + + $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); + $trial_length = WC_Subscriptions_Product::get_trial_length( $product ); + + if ( $trial_length > 0 ) { + $price = $sign_up_fee; + } else { + $price += $sign_up_fee; + } + } // else $price = recurring amount already as WC_Product->get_price() returns subscription price + + $price = apply_filters( 'woocommerce_subscriptions_cart_get_price', $price, $product ); + + // Make sure the recurring amount for any non-subscription products in the cart with a subscription is $0 + } elseif ( 'recurring_total' == self::$calculation_type ) { + + $price = 0; + + } + + return $price; + } + + /** + * Calculate the initial and recurring totals for all subscription products in the cart. + * + * We need to group subscriptions by billing schedule to make the display and creation of recurring totals sane, + * when there are multiple subscriptions in the cart. To do that, we use an array with keys of the form: + * '{billing_interval}_{billing_period}_{trial_interval}_{trial_period}_{length}_{billing_period}'. This key + * is used to reference WC_Cart objects for each recurring billing schedule and these are stored in the master + * cart with the billing schedule key. + * + * After we have calculated and grouped all recurring totals, we need to checks the structure of the subscription + * product prices to see whether they include sign-up fees and/or free trial periods and then recalculates the + * appropriate totals by using the @see self::$calculation_type flag and cloning the cart to run @see WC_Cart::calculate_totals() + * + * @since 1.3.5 + * @version 2.0 + */ + public static function calculate_subscription_totals( $total, $cart ) { + + if ( ! self::cart_contains_subscription() && ! wcs_cart_contains_resubscribe() ) { // cart doesn't contain subscription + return $total; + } elseif ( 'none' != self::$calculation_type ) { // We're in the middle of a recalculation, let it run + return $total; + } + + // Save the original cart values/totals, as we'll use this when there is no sign-up fee + WC()->cart->total = ( $total < 0 ) ? 0 : $total; + + do_action( 'woocommerce_subscription_cart_before_grouping' ); + + $subscription_groups = array(); + + // Group the subscription items by their cart item key based on billing schedule + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + + if ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $subscription_groups[ self::get_recurring_cart_key( $cart_item ) ][] = $cart_item_key; + } + } + + do_action( 'woocommerce_subscription_cart_after_grouping' ); + + $recurring_carts = array(); + + // Back up the shipping method. Chances are WC is going to wipe the chosen_shipping_methods data + WC()->session->set( 'wcs_shipping_methods', WC()->session->get( 'chosen_shipping_methods', array() ) ); + + // Now let's calculate the totals for each group of subscriptions + self::$calculation_type = 'recurring_total'; + + foreach ( $subscription_groups as $recurring_cart_key => $subscription_group ) { + + // Create a clone cart to calculate and store totals for this group of subscriptions + $recurring_cart = clone WC()->cart; + $product = null; + + self::$recurring_cart_key = $recurring_cart->recurring_cart_key = $recurring_cart_key; + + // Remove any items not in this subscription group + foreach ( $recurring_cart->get_cart() as $cart_item_key => $cart_item ) { + if ( ! in_array( $cart_item_key, $subscription_group ) ) { + unset( $recurring_cart->cart_contents[ $cart_item_key ] ); + continue; + } + + if ( null === $product ) { + $product = $cart_item['data']; + } + } + + $recurring_cart->start_date = apply_filters( 'wcs_recurring_cart_start_date', gmdate( 'Y-m-d H:i:s' ), $recurring_cart ); + $recurring_cart->trial_end_date = apply_filters( 'wcs_recurring_cart_trial_end_date', WC_Subscriptions_Product::get_trial_expiration_date( $product, $recurring_cart->start_date ), $recurring_cart, $product ); + $recurring_cart->next_payment_date = apply_filters( 'wcs_recurring_cart_next_payment_date', WC_Subscriptions_Product::get_first_renewal_payment_date( $product, $recurring_cart->start_date ), $recurring_cart, $product ); + $recurring_cart->end_date = apply_filters( 'wcs_recurring_cart_end_date', WC_Subscriptions_Product::get_expiration_date( $product, $recurring_cart->start_date ), $recurring_cart, $product ); + + // No fees recur (yet) + $recurring_cart->fees = array(); + $recurring_cart->fee_total = 0; + WC()->shipping->reset_shipping(); + self::maybe_restore_shipping_methods(); + $recurring_cart->calculate_totals(); + + // Store this groups cart details + $recurring_carts[ $recurring_cart_key ] = clone $recurring_cart; + + // And remove some other floatsam + $recurring_carts[ $recurring_cart_key ]->removed_cart_contents = array(); + $recurring_carts[ $recurring_cart_key ]->cart_session_data = array(); + + // Keep a record of the shipping packages so we can add them to the global packages later + self::$recurring_shipping_packages[ $recurring_cart_key ] = WC()->shipping->get_packages(); + } + + self::$calculation_type = self::$recurring_cart_key = 'none'; + + // We need to reset the packages and totals stored in WC()->shipping too + WC()->shipping->reset_shipping(); + self::maybe_restore_shipping_methods(); + WC()->cart->calculate_shipping(); + + // We no longer need our backup of shipping methods + unset( WC()->session->wcs_shipping_methods ); + + // If there is no sign-up fee and a free trial, and no products being purchased with the subscription, we need to zero the fees for the first billing period + if ( 0 == self::get_cart_subscription_sign_up_fee() && self::all_cart_items_have_free_trial() ) { + foreach ( WC()->cart->get_fees() as $fee_index => $fee ) { + WC()->cart->fees[ $fee_index ]->amount = 0; + WC()->cart->fees[ $fee_index ]->tax = 0; + } + WC()->cart->fee_total = 0; + } + + WC()->cart->recurring_carts = $recurring_carts; + + $total = max( 0, round( WC()->cart->cart_contents_total + WC()->cart->tax_total + WC()->cart->shipping_tax_total + WC()->cart->shipping_total + WC()->cart->fee_total, WC()->cart->dp ) ); + + if ( isset( WC()->cart->discount_total ) && 0 !== WC()->cart->discount_total ) { // WC < 2.3, deduct deprecated after tax discount total + $total = max( 0, round( $total - WC()->cart->discount_total, WC()->cart->dp ) ); + } + + if ( ! self::charge_shipping_up_front() ) { + $total = max( 0, $total - WC()->cart->shipping_tax_total - WC()->cart->shipping_total ); + WC()->cart->shipping_taxes = array(); + WC()->cart->shipping_tax_total = 0; + WC()->cart->shipping_total = 0; + } + + return apply_filters( 'woocommerce_subscriptions_calculated_total', $total ); + } + + /** + * Check whether shipping should be charged on the initial order. + * + * When the cart contains a physical subscription with a free trial and no other physical items, shipping + * should not be charged up-front. + * + * @since 1.5.4 + */ + public static function charge_shipping_up_front() { + + $charge_shipping_up_front = true; + + if ( self::all_cart_items_have_free_trial() ) { + + $charge_shipping_up_front = false; + $other_items_need_shipping = false; + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + if ( ! WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) && $cart_item['data']->needs_shipping() ) { + $other_items_need_shipping = true; + } + } + + if ( false === $other_items_need_shipping ) { + $charge_shipping_up_front = false; + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_shipping_up_front', $charge_shipping_up_front ); + } + + /** + * The cart needs shipping only if it needs shipping up front and/or for recurring items. + * + * @since 2.0 + */ + public static function cart_needs_shipping( $needs_shipping ) { + + if ( self::cart_contains_subscription() ) { + if ( 'none' == self::$calculation_type ) { + if ( true == $needs_shipping && ! self::charge_shipping_up_front() && ! self::cart_contains_subscriptions_needing_shipping() ) { + $needs_shipping = false; + } elseif ( false == $needs_shipping && ( self::charge_shipping_up_front() || self::cart_contains_subscriptions_needing_shipping() ) ) { + $needs_shipping = false; + } + } elseif ( 'recurring_total' == self::$calculation_type ) { + if ( true == $needs_shipping && ! self::cart_contains_subscriptions_needing_shipping() ) { + $needs_shipping = false; + } elseif ( false == $needs_shipping && self::cart_contains_subscriptions_needing_shipping() ) { + $needs_shipping = true; + } + } + } + + return $needs_shipping; + } + + /** + * Remove all recurring shipping methods stored in the session (i.e. methods with a key that is a string) + * + * This is attached as a callback to hooks triggered whenever a product is removed from the cart. + * + * @param $cart_item_key string The key for a cart item about to be removed from the cart. + * @return null + * @since 2.0.15 + */ + public static function maybe_reset_chosen_shipping_methods( $cart_item_key ) { + + if ( isset( WC()->cart->cart_contents[ $cart_item_key ] ) ) { + + $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + + // Remove all recurring methods + foreach ( $chosen_methods as $key => $methods ) { + if ( ! is_numeric( $key ) ) { + unset( $chosen_methods[ $key ] ); + } + } + + WC()->session->set( 'chosen_shipping_methods', $chosen_methods ); + } + } + + /** + * Parse recurring shipping rates from the front end and put them into the $_POST['shipping_method'] used by WooCommerce. + * + * When WooCommerce takes the value of inputs for shipping methods selection from the cart and checkout pages, it uses a + * JavaScript array and therefore, can only use numerical indexes. This works for WC core, because it only needs shipping + * selection for different packages. However, we want to use string indexes to differentiate between different recurring + * cart shipping selection inputs *and* packages. To do this, we need to get our shipping methods from the $_POST['post_data'] + * values and manually add them $_POST['shipping_method'] array. + * + * We can't do this on the cart page unfortunately because it doesn't pass the entire forms post data and instead only + * sends the shipping methods with a numerical index. + * + * @return null + * @since 2.0.12 + */ + public static function add_shipping_method_post_data() { + + if ( ! WC_Subscriptions::is_woocommerce_pre( '2.6' ) ) { + return; + } + + check_ajax_referer( 'update-order-review', 'security' ); + + parse_str( $_POST['post_data'], $form_data ); + + // In case we have only free trials/sync'd products in the cart and shipping methods aren't being displayed + if ( ! isset( $_POST['shipping_method'] ) ) { + $_POST['shipping_method'] = array(); + } + if ( ! isset( $form_data['shipping_method'] ) ) { + $form_data['shipping_method'] = array(); + } + + foreach ( $form_data['shipping_method'] as $key => $methods ) { + if ( ! is_numeric( $key ) && ! array_key_exists( $key, $_POST['shipping_method'] ) ) { + $_POST['shipping_method'][ $key ] = $methods; + } + } + } + + /** + * Set the chosen shipping method for recurring cart calculations + * + * In WC_Shipping::calculate_shipping(), WooCommerce tries to determine the chosen shipping method + * based on the package index and stores rates. However, for recurring cart shipping selection, we + * use the recurring cart key instead of numeric index. Therefore, we need to hook in to override + * the default shipping method when WooCommerce could not find a matching shipping method. + * + * @param string $default_method the default shipping method for the customer/store returned by WC_Shipping::get_default_method() + * @param array $available_methods set of shipping rates for this calculation + * @param int $package_index WC doesn't pass the package index to callbacks on the 'woocommerce_shipping_chosen_method' filter (yet) so we set a default value of 0 for it in the function params + * @since 2.0.12 + */ + public static function set_chosen_shipping_method( $default_method, $available_methods, $package_index = 0 ) { + + $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + + $recurring_cart_package_key = self::get_recurring_shipping_package_key( self::$recurring_cart_key, $package_index ); + + if ( 'none' !== self::$recurring_cart_key && isset( $chosen_methods[ $recurring_cart_package_key ] ) && isset( $available_methods[ $chosen_methods[ $recurring_cart_package_key ] ] ) ) { + $default_method = $chosen_methods[ $recurring_cart_package_key ]; + + // Our dummy rate ended up being set as the default method (probably because it has no priority) so we need to re-run some logic from WC_Shipping::get_default_method() to find the actual default method + } elseif ( 'wcs_dummy_rate' === $default_method && ! empty( $available_methods ) ) { + + unset( $available_methods['wcs_dummy_rate'] ); + + // Order by priorities and costs + $selection_priority = get_option( 'woocommerce_shipping_method_selection_priority', array() ); + $prioritized_methods = array(); + + foreach ( $available_methods as $method_key => $method ) { + // Some IDs contain : if they have multiple rates so use $method->method_id + $priority = isset( $selection_priority[ $method->method_id ] ) ? absint( $selection_priority[ $method->method_id ] ): 1; + + if ( empty( $prioritized_methods[ $priority ] ) ) { + $prioritized_methods[ $priority ] = array(); + } + + $prioritized_methods[ $priority ][ $method_key ] = $method->cost; + } + + ksort( $prioritized_methods ); + $prioritized_methods = current( $prioritized_methods ); + asort( $prioritized_methods ); + + $default_method = current( array_keys( $prioritized_methods ) ); + + // Set the chosen shipping method (if available) to workaround a bug with WC_Shipping::get_default_method() in WC < 2.6 which leads to the default shipping method always being used instead of a valid chosen shipping method + } elseif ( isset( $chosen_methods[ $package_index ] ) && $default_method !== $chosen_methods[ $package_index ] && WC_Subscriptions::is_woocommerce_pre( '2.6' ) ) { + $default_method = $chosen_methods[ $package_index ]; + } + + return $default_method; + } + + /** + * Create a shipping package index for a given shipping package on a recurring cart. + * + * @param string $recurring_cart_key a cart key of the form returned by @see self::get_recurring_cart_key() + * @param int $package_index the index of a package + * @since 2.0.12 + */ + public static function get_recurring_shipping_package_key( $recurring_cart_key, $package_index ) { + return $recurring_cart_key . '_' . $package_index; + } + + /** + * Add the shipping packages stored in @see self::$recurring_shipping_packages to WooCommerce's global + * set of packages in WC()->shipping->packages so that plugins attempting to get the details of recurring + * packages can get them with WC()->shipping->get_packages() like any other packages. + * + * @since 2.0.13 + */ + public static function set_global_recurring_shipping_packages() { + foreach ( self::$recurring_shipping_packages as $recurring_cart_key => $packages ) { + foreach ( $packages as $package_index => $package ) { + WC()->shipping->packages[ self::get_recurring_shipping_package_key( $recurring_cart_key, $package_index ) ] = $package; + } + } + } + + /** + * When WooCommerce calculates rates for a recurring shipping package, we want to return both a different number + * of rates, and a unique set of rates for the recurring shipping package to make sure WooCommerce updates the + * chosen method for the recurring cart (and the 'woocommerce_shipping_chosen_method' filter is called, which + * we use to make sure the chosen method is the recurring method, not the initial method). + * + * This function is hooked to 'woocommerce_package_rates' called by WC_Shipping->calculate_shipping_for_package() + * + * For more details, see: + * - https://github.com/Prospress/woocommerce-subscriptions/pull/1187#issuecomment-186091152 + * - https://github.com/Prospress/woocommerce-subscriptions/pull/1187#issuecomment-187602311 + * + * @param array $package_rates A set of shipping method objects in the form of WC_Shipping_Rate->id => WC_Shipping_Rate with the cost for that rate + * @param array $package A shipping package of the form returned by WC_Cart->get_shipping_packages() which includes the package's contents, cost, customer, destination and alternative rates + * @since 2.0.12 + */ + public static function filter_package_rates( $package_rates, $package ) { + + if ( 'none' !== self::$recurring_cart_key ) { + + $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + $recurring_cart_shipping_methods = array(); + $recurring_package_count = 0; + + foreach ( $chosen_methods as $package_index => $chosen_method_name ) { + if ( self::get_recurring_shipping_package_key( self::$recurring_cart_key, $recurring_package_count ) == $package_index ) { + $recurring_cart_shipping_methods[ $chosen_method_name ] = $chosen_method_name; + $recurring_package_count++; + } + } + + if ( 0 < count( $recurring_cart_shipping_methods ) ) { + + $unique_package_rates = array_intersect_key( $package_rates, $recurring_cart_shipping_methods ); + + // if we have no unique package rates, the cached chosen shipping method has been disabled or is no longer available, so instead of filtering the available rates to only that method, we need to add a new dummy method to make sure the available rates count is different, this is only necessary when there is only one available method because when there is more than one, the selection fields will be displayed and the customer can choose the method + if ( empty( $unique_package_rates ) && 1 == count( $package_rates ) ) { + $package_rates['wcs_dummy_rate'] = current( $package_rates ); + } else { + $package_rates = $unique_package_rates; + } + } + + // We need to make sure both the number of rates and the contents of each rate are different to ensure that we bypass WC's cache, so let's add our own unique key on the rate + foreach ( $package_rates as $method_name => $method ) { + $package_rates[ $method_name ]->recurring_cart_key = self::$recurring_cart_key; + } + } + + return $package_rates; + } + + /** + * Check whether all the subscription product items in the cart have a free trial. + * + * Useful for determining if certain up-front amounts should be charged. + * + * @since 2.0 + */ + public static function all_cart_items_have_free_trial() { + + $all_items_have_free_trial = true; + + foreach ( WC()->cart->get_cart() as $cart_item ) { + if ( ! WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $all_items_have_free_trial = false; + break; + } else { + $trial_length = ( isset( $cart_item['data']->subscription_trial_length ) ) ? $cart_item['data']->subscription_trial_length : WC_Subscriptions_Product::get_trial_length( $cart_item['data'] ); + if ( 0 == $trial_length ) { + $all_items_have_free_trial = false; + break; + } + } + } + + return apply_filters( 'woocommerce_subscriptions_all_cart_items_have_free_trial', $all_items_have_free_trial ); + } + + /** + * Check if the cart contains a subscription which requires shipping. + * + * @since 1.5.4 + */ + public static function cart_contains_subscriptions_needing_shipping() { + + if ( 'no' === get_option( 'woocommerce_calc_shipping' ) ) { + return false; + } + + $cart_contains_subscriptions_needing_shipping = false; + + if ( self::cart_contains_subscription() ) { + foreach ( WC()->cart->cart_contents as $cart_item_key => $values ) { + $_product = $values['data']; + if ( WC_Subscriptions_Product::is_subscription( $_product ) && $_product->needs_shipping() && 'yes' !== $_product->subscription_one_time_shipping ) { + $cart_contains_subscriptions_needing_shipping = true; + } + } + } + + return apply_filters( 'woocommerce_cart_contains_subscriptions_needing_shipping', $cart_contains_subscriptions_needing_shipping ); + } + + /** + * Filters the cart contents to remove any subscriptions with free trials (or synchronised to a date in the future) + * to make sure no shipping amount is calculated for them. + * + * @since 2.0 + */ + public static function set_cart_shipping_packages( $packages ) { + + if ( self::cart_contains_subscription() ) { + if ( 'none' == self::$calculation_type ) { + foreach ( $packages as $index => $package ) { + foreach ( $package['contents'] as $cart_item_key => $cart_item ) { + $trial_length = ( isset( $cart_item['data']->subscription_trial_length ) ) ? $cart_item['data']->subscription_trial_length : WC_Subscriptions_Product::get_trial_length( $cart_item['data'] ); + if ( $trial_length > 0 ) { + unset( $packages[ $index ]['contents'][ $cart_item_key ] ); + } + } + + if ( empty( $packages[ $index ]['contents'] ) ) { + unset( $packages[ $index ] ); + } + } + } elseif ( 'recurring_total' == self::$calculation_type ) { + foreach ( $packages as $index => $package ) { + foreach ( $package['contents'] as $cart_item_key => $cart_item ) { + if ( isset( $cart_item['data']->subscription_one_time_shipping ) && 'yes' == $cart_item['data']->subscription_one_time_shipping ) { + $packages[ $index ]['contents_cost'] -= $cart_item['line_total']; + unset( $packages[ $index ]['contents'][ $cart_item_key ] ); + } + } + + if ( empty( $packages[ $index ]['contents'] ) ) { + unset( $packages[ $index ] ); + } else { + // we need to make sure the package is different for recurring carts to bypass WC's cache + $packages[ $index ]['recurring_cart_key'] = self::$recurring_cart_key; + } + } + } + } + + return $packages; + } + + /* Formatted Totals Functions */ + + /** + * Returns the subtotal for a cart item including the subscription period and duration details + * + * @since 1.0 + */ + public static function get_formatted_product_subtotal( $product_subtotal, $product, $quantity, $cart ) { + + if ( WC_Subscriptions_Product::is_subscription( $product ) && ! wcs_cart_contains_renewal() ) { + + // Avoid infinite loop + remove_filter( 'woocommerce_cart_product_subtotal', __CLASS__ . '::get_formatted_product_subtotal', 11, 4 ); + + add_filter( 'woocommerce_get_price', 'WC_Subscriptions_Product::get_sign_up_fee_filter', 100, 2 ); + + // And get the appropriate sign up fee string + $sign_up_fee_string = $cart->get_product_subtotal( $product, $quantity ); + + remove_filter( 'woocommerce_get_price', 'WC_Subscriptions_Product::get_sign_up_fee_filter', 100, 2 ); + + add_filter( 'woocommerce_cart_product_subtotal', __CLASS__ . '::get_formatted_product_subtotal', 11, 4 ); + + $product_subtotal = WC_Subscriptions_Product::get_price_string( $product, array( + 'price' => $product_subtotal, + 'sign_up_fee' => $sign_up_fee_string, + 'tax_calculation' => WC()->cart->tax_display_cart, + ) + ); + + if ( false !== strpos( $product_subtotal, WC()->countries->inc_tax_or_vat() ) ) { + $product_subtotal = str_replace( WC()->countries->inc_tax_or_vat(), '', $product_subtotal ) . ' ' . WC()->countries->inc_tax_or_vat() . ''; + } + if ( false !== strpos( $product_subtotal, WC()->countries->ex_tax_or_vat() ) ) { + $product_subtotal = str_replace( WC()->countries->ex_tax_or_vat(), '', $product_subtotal ) . ' ' . WC()->countries->ex_tax_or_vat() . ''; + } + + $product_subtotal = '' . $product_subtotal . ''; + } + + return $product_subtotal; + } + + /* + * Helper functions for extracting the details of subscriptions in the cart + */ + + /** + * Checks the cart to see if it contains a subscription product. + * + * @since 1.0 + */ + public static function cart_contains_subscription() { + + $contains_subscription = false; + + if ( ! empty( WC()->cart->cart_contents ) && ! wcs_cart_contains_renewal() ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $contains_subscription = true; + break; + } + } + } + + return $contains_subscription; + } + + /** + * Checks the cart to see if it contains a subscription product with a free trial + * + * @since 1.2 + */ + public static function cart_contains_free_trial() { + + $cart_contains_free_trial = false; + + if ( self::cart_contains_subscription() ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['data']->subscription_trial_length ) && $cart_item['data']->subscription_trial_length > 0 ) { + $cart_contains_free_trial = true; + break; + } elseif ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) && WC_Subscriptions_Product::get_trial_length( $cart_item['data'] ) > 0 ) { + $cart_contains_free_trial = true; + break; + } + } + } + + return $cart_contains_free_trial; + } + + /** + * Gets the cart calculation type flag + * + * @since 1.2 + */ + public static function get_calculation_type() { + return self::$calculation_type; + } + + /** + * Sets the cart calculation type flag + * + * @since 2.0 + */ + public static function set_calculation_type( $calculation_type ) { + + self::$calculation_type = $calculation_type; + + return $calculation_type; + } + + /** + * Gets the subscription sign up fee for the cart and returns it + * + * Currently short-circuits to return just the sign-up fee of the first subscription, because only + * one subscription can be purchased at a time. + * + * @since 1.0 + */ + public static function get_cart_subscription_sign_up_fee() { + + $sign_up_fee = 0; + + if ( self::cart_contains_subscription() || wcs_cart_contains_renewal() ) { + + $renewal_item = wcs_cart_contains_renewal(); + + foreach ( WC()->cart->cart_contents as $cart_item ) { + + // Renewal items do not have sign-up fees + if ( $renewal_item == $cart_item ) { + continue; + } + + if ( isset( $cart_item['data']->subscription_sign_up_fee ) ) { + $sign_up_fee += $cart_item['data']->subscription_sign_up_fee; + } elseif ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $sign_up_fee += WC_Subscriptions_Product::get_sign_up_fee( $cart_item['data'] ); + } + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_sign_up_fee', $sign_up_fee ); + } + + /** + * Check whether the cart needs payment even if the order total is $0 + * + * @param bool $needs_payment The existing flag for whether the cart needs payment or not. + * @param WC_Cart $cart The WooCommerce cart object. + * @return bool + */ + public static function cart_needs_payment( $needs_payment, $cart ) { + + if ( false === $needs_payment && self::cart_contains_subscription() && $cart->total == 0 && false === WC_Subscriptions_Switcher::cart_contains_switches() && 'yes' !== get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) ) { + + $recurring_total = 0; + $is_one_period = true; + $is_synced = false; + + foreach ( WC()->cart->recurring_carts as $cart ) { + + $recurring_total += $cart->total; + + $cart_length = wcs_cart_pluck( $cart, 'subscription_length' ); + + if ( 0 == $cart_length || wcs_cart_pluck( $cart, 'subscription_period_interval' ) != $cart_length ) { + $is_one_period = false; + } + + $is_synced = ( $is_synced || false != WC_Subscriptions_Synchroniser::cart_contains_synced_subscription( $cart ) ) ? true : false; + } + + $has_trial = self::cart_contains_free_trial(); + + if ( $recurring_total > 0 && ( false === $is_one_period || true === $has_trial || ( false !== $is_synced && false == WC_Subscriptions_Synchroniser::is_today( WC_Subscriptions_Synchroniser::calculate_first_payment_date( $is_synced['data'], 'timestamp' ) ) ) ) ) { + $needs_payment = true; + } + } + + return $needs_payment; + } + + /** + * Restore shipping method, as well as cost and tax estimate when on the cart page. + * + * The WC_Shortcode_Cart actually calculates shipping when the "Calculate Shipping" form is submitted on the + * cart page. Because of that, our own @see self::calculate_totals() method calculates incorrect values on + * the cart page because it triggers the method multiple times for multiple different pricing structures. + * This uses the same logic found in WC_Shortcode_Cart::output() to determine the correct estimate. + * + * @since 1.4.10 + */ + private static function maybe_restore_shipping_methods() { + if ( ! empty( $_POST['calc_shipping'] ) && wp_verify_nonce( $_POST['_wpnonce'], 'woocommerce-cart' ) && function_exists( 'WC' ) ) { + + try { + WC()->shipping->reset_shipping(); + + $country = wc_clean( $_POST['calc_shipping_country'] ); + $state = isset( $_POST['calc_shipping_state'] ) ? wc_clean( $_POST['calc_shipping_state'] ) : ''; + $postcode = apply_filters( 'woocommerce_shipping_calculator_enable_postcode', true ) ? wc_clean( $_POST['calc_shipping_postcode'] ) : ''; + $city = apply_filters( 'woocommerce_shipping_calculator_enable_city', false ) ? wc_clean( $_POST['calc_shipping_city'] ) : ''; + + if ( $postcode && ! WC_Validation::is_postcode( $postcode, $country ) ) { + throw new Exception( __( 'Please enter a valid postcode/ZIP.', 'woocommerce-subscriptions' ) ); + } elseif ( $postcode ) { + $postcode = wc_format_postcode( $postcode, $country ); + } + + if ( $country ) { + WC()->customer->set_location( $country, $state, $postcode, $city ); + WC()->customer->set_shipping_location( $country, $state, $postcode, $city ); + } else { + WC()->customer->set_to_base(); + WC()->customer->set_shipping_to_base(); + } + + WC()->customer->calculated_shipping( true ); + + do_action( 'woocommerce_calculated_shipping' ); + + } catch ( Exception $e ) { + if ( ! empty( $e ) ) { + wc_add_notice( $e->getMessage(), 'error' ); + } + } + } + + // If we had one time shipping in the carts, we may have wiped the WC chosen shippings. Restore them. + self::maybe_restore_chosen_shipping_method(); + + if ( isset( $_POST['shipping_method'] ) && is_array( $_POST['shipping_method'] ) ) { + + // Now make sure the correct shipping method is set + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + + foreach ( $_POST['shipping_method'] as $i => $value ) { + $chosen_shipping_methods[ $i ] = wc_clean( $value ); + } + + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); + } + } + + /** + * Make sure cart product prices correctly include/exclude taxes. + * + * @since 1.5.8 + */ + public static function cart_product_price( $price, $product ) { + + if ( WC_Subscriptions_Product::is_subscription( $product ) ) { + $price = WC_Subscriptions_Product::get_price_string( $product, array( 'price' => $price, 'tax_calculation' => WC()->cart->tax_display_cart ) ); + } + + return $price; + } + + /** + * Make sure cart totals are calculated when the cart widget is populated via the get_refreshed_fragments() method + * so that @see self::get_formatted_cart_subtotal() returns the correct subtotal price string. + * + * @since 1.5.11 + */ + public static function pre_get_refreshed_fragments() { + if ( defined( 'DOING_AJAX' ) && true === DOING_AJAX && ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + WC()->cart->calculate_totals(); + } + } + + /** + * Display the recurring totals for items in the cart + * + * @since 2.0 + */ + public static function display_recurring_totals() { + + if ( self::cart_contains_subscription() ) { + + // We only want shipping for recurring amounts, and they need to be calculated again here + self::$calculation_type = 'recurring_total'; + + $shipping_methods = array(); + + $carts_with_multiple_payments = 0; + + // Create new subscriptions for each subscription product in the cart (that is not a renewal) + foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { + + // Cart contains more than one payment + if ( 0 != $recurring_cart->next_payment_date ) { + $carts_with_multiple_payments++; + } + } + + if ( $carts_with_multiple_payments >= 1 ) { + wc_get_template( 'checkout/recurring-totals.php', array( 'shipping_methods' => $shipping_methods, 'recurring_carts' => WC()->cart->recurring_carts, 'carts_with_multiple_payments' => $carts_with_multiple_payments ), '', plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + } + + self::$calculation_type = 'none'; + } + } + + /** + * Construct a cart key based on the billing schedule of a subscription product. + * + * Subscriptions groups products by billing schedule when calculating cart totals, so that shipping and other "per order" amounts + * can be calculated for each group of items for each renewal. This method constructs a cart key based on the billing schedule + * to allow products on the same billing schedule to be grouped together - free trials and synchronisation is accounted for by + * using the first renewal date (if any) for the susbcription. + * + * @since 2.0 + */ + public static function get_recurring_cart_key( $cart_item, $renewal_time = '' ) { + + $cart_key = ''; + + $product = $cart_item['data']; + $product_id = ! empty( $product->variation_id ) ? $product->variation_id : $product->id; + $renewal_time = ! empty( $renewal_time ) ? $renewal_time : WC_Subscriptions_Product::get_first_renewal_payment_time( $product_id ); + $interval = WC_Subscriptions_Product::get_interval( $product ); + $period = WC_Subscriptions_Product::get_period( $product ); + $length = WC_Subscriptions_Product::get_length( $product ); + $trial_period = WC_Subscriptions_Product::get_trial_period( $product ); + $trial_length = WC_Subscriptions_Product::get_trial_length( $product ); + + if ( $renewal_time > 0 ) { + $cart_key .= date( 'Y_m_d_', $renewal_time ); + } + + // First start with the billing interval and period + switch ( $interval ) { + case 1 : + if ( 'day' == $period ) { + $cart_key .= 'daily'; // always gotta be one exception + } else { + $cart_key .= sprintf( '%sly', $period ); + } + break; + case 2 : + $cart_key .= sprintf( 'every_2nd_%s', $period ); + break; + case 3 : + $cart_key .= sprintf( 'every_3rd_%s', $period ); // or sometimes two exceptions it would seem + break; + default: + $cart_key .= sprintf( 'every_%dth_%s', $interval, $period ); + break; + } + + if ( $length > 0 ) { + $cart_key .= '_for_'; + $cart_key .= sprintf( '%d_%s', $length, $period ); + if ( $length > 1 ) { + $cart_key .= 's'; + } + } + + if ( $trial_length > 0 ) { + $cart_key .= sprintf( '_after_a_%d_%s_trial', $trial_length, $trial_period ); + } + + return apply_filters( 'woocommerce_subscriptions_recurring_cart_key', $cart_key, $cart_item ); + } + + /** + * Don't allow other subscriptions to be added to the cart while it contains a renewal + * + * @since 2.0 + */ + public static function check_valid_add_to_cart( $is_valid, $product, $quantity ) { + + if ( $is_valid && wcs_cart_contains_renewal() && WC_Subscriptions_Product::is_subscription( $product ) ) { + + wc_add_notice( __( 'That subscription product can not be added to your cart as it already contains a subscription renewal.', 'woocommerce-subscriptions' ), 'error' ); + $is_valid = false; + } + + return $is_valid; + } + + /** + * When calculating shipping for recurring carts, return a revised list of shipping methods that apply to this recurring cart. + * + * When WooCommerce determines the taxable address for local pick up methods, we only want to return pick up shipping methods + * chosen for the recurring cart being calculated instead of all methods. + * + * @param array $shipping_methods + * + * @since 2.0.13 + */ + public static function filter_recurring_cart_chosen_shipping_method( $shipping_methods ) { + + if ( 'recurring_total' == self::$calculation_type && 'none' !== self::$recurring_cart_key ) { + + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + + $standard_package_methods = array(); + $recurring_cart_shipping_methods = array(); + + foreach ( $chosen_shipping_methods as $key => $method ) { + + if ( is_numeric( $key ) ) { + $standard_package_methods[ $key ] = $method; + + } else if ( strpos( $key, self::$recurring_cart_key ) !== false ) { + + $recurring_cart_shipping_methods[ $key ] = $method; + } + } + + // pick which chosen methods apply to this recurring cart. Defaults to standard methods if there is no specific recurring cart shipping methods chosen. + $applicable_chosen_shipping_methods = ( empty( $recurring_cart_shipping_methods ) ) ? $standard_package_methods : $recurring_cart_shipping_methods; + + $shipping_methods = array_intersect( $applicable_chosen_shipping_methods, $shipping_methods ); + } + + return $shipping_methods; + } + + /** + * Validate the chosen recurring shipping methods for each recurring shipping package. + * Ensures there is at least one chosen shipping method and that the chosen method is valid considering the available + * package rates. + * + * @since 2.0.14 + */ + public static function validate_recurring_shipping_methods() { + + $shipping_methods = WC()->checkout()->shipping_methods; + $added_invalid_notice = false; + $standard_packages = WC()->shipping->get_packages(); + + foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { + + if ( false === $recurring_cart->needs_shipping() || 0 == $recurring_cart->next_payment_date ) { + continue; + } + + $packages = $recurring_cart->get_shipping_packages(); + + foreach ( $packages as $package_index => $base_package ) { + $package = WC()->shipping->calculate_shipping_for_package( $base_package ); + + if ( ( isset( $standard_packages[ $package_index ] ) && $package['rates'] == $standard_packages[ $package_index ]['rates'] ) && apply_filters( 'wcs_cart_totals_shipping_html_price_only', true, $package, WC()->cart->recurring_carts[ $recurring_cart_key ] ) ) { + // the recurring package rates match the initial package rates, there won't be a selected shipping method for this recurring cart package + // move on to the next package + continue; + } + + $recurring_shipping_package_key = WC_Subscriptions_Cart::get_recurring_shipping_package_key( $recurring_cart_key, $package_index ); + + if ( ! isset( $package['rates'][ $shipping_methods[ $recurring_shipping_package_key ] ] ) ) { + + if ( ! $added_invalid_notice ) { + wc_add_notice( __( 'Invalid recurring shipping method.', 'woocommerce-subscriptions' ), 'error' ); + $added_invalid_notice = true; + } + + WC()->checkout()->shipping_methods[ $recurring_shipping_package_key ] = ''; + } + } + } + } + + /** + * Checks the cart to see if it contains a specific product. + * + * @param int The product ID or variation ID to look for. + * @return bool Whether the product is in the cart. + * @since 2.0.13 + */ + public static function cart_contains_product( $product_id ) { + + $cart_contains_product = false; + + if ( ! empty( WC()->cart->cart_contents ) ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( wcs_get_canonical_product_id( $cart_item ) == $product_id ) { + $cart_contains_product = true; + break; + } + } + } + + return $cart_contains_product; + } + + /* Deprecated */ + + /** + * Returns the formatted subscription price string for an item + * + * @since 1.0 + */ + public static function get_cart_item_price_html( $price_string, $cart_item ) { + + _deprecated_function( __METHOD__, '1.2' ); + + return $price_string; + } + + /** + * Returns either the total if prices include tax because this doesn't include tax, or the + * subtotal if prices don't includes tax, because this doesn't include tax. + * + * @return string formatted price + * + * @since 1.0 + */ + public static function get_cart_contents_total( $cart_contents_total ) { + + _deprecated_function( __METHOD__, '1.2' ); + + return $cart_contents_total; + } + + /** + * Calculate totals for the sign-up fees in the cart, based on @see WC_Cart::calculate_totals() + * + * @since 1.0 + */ + public static function calculate_sign_up_fee_totals() { + _deprecated_function( __METHOD__, '1.2' ); + } + + /** + * Function to apply discounts to a product and get the discounted price (before tax is applied) + * + * @param mixed $values + * @param mixed $price + * @param bool $add_totals (default: false) + * @return float price + * @since 1.0 + */ + public static function get_discounted_price( $values, $price, $add_totals = false ) { + + _deprecated_function( __METHOD__, '1.2' ); + + return $price; + } + + /** + * Function to apply product discounts after tax + * + * @param mixed $values + * @param mixed $price + * @since 1.0 + */ + public static function apply_product_discounts_after_tax( $values, $price ) { + _deprecated_function( __METHOD__, '1.2' ); + } + + /** + * Function to apply cart discounts after tax + * + * @since 1.0 + */ + public static function apply_cart_discounts_after_tax() { + _deprecated_function( __METHOD__, '1.2' ); + } + + /** + * Get tax row amounts with or without compound taxes includes + * + * @return float price + */ + public static function get_sign_up_taxes_total( $compound = true ) { + _deprecated_function( __METHOD__, '1.2' ); + return 0; + } + + public static function get_sign_up_fee_fields() { + _deprecated_function( __METHOD__, '1.2' ); + + return array( + 'cart_contents_sign_up_fee_total', + 'cart_contents_sign_up_fee_count', + 'sign_up_fee_total', + 'sign_up_fee_subtotal', + 'sign_up_fee_subtotal_ex_tax', + 'sign_up_fee_tax_total', + 'sign_up_fee_taxes', + 'sign_up_fee_discount_cart', + 'sign_up_fee_discount_total', + ); + } + + /** + * Returns the subtotal for a cart item including the subscription period and duration details + * + * @since 1.0 + */ + public static function get_product_subtotal( $product_subtotal, $product ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ .'::get_formatted_product_subtotal( $product_subtotal, $product )' ); + return self::get_formatted_product_subtotal( $product_subtotal, $product ); + } + + /** + * Returns a string with the cart discount and subscription period. + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function get_discounts_before_tax( $discount, $cart ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ .'::get_formatted_discounts_before_tax( $discount )' ); + return self::get_formatted_discounts_before_tax( $discount ); + } + + /** + * Gets the order discount amount - these are applied after tax + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function get_discounts_after_tax( $discount, $cart ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ .'::get_formatted_discounts_after_tax( $discount )' ); + return self::get_formatted_discounts_after_tax( $discount ); + } + + /** + * Includes the sign-up fee subtotal in the subtotal displayed in the cart. + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function get_cart_subtotal( $cart_subtotal, $compound, $cart ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ .'::get_formatted_cart_subtotal( $cart_subtotal, $compound, $cart )' ); + return self::get_formatted_cart_subtotal( $cart_subtotal, $compound, $cart ); + } + + /** + * Appends the cart subscription string to a cart total using the @see self::get_cart_subscription_string and then returns it. + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function get_total( $total ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ .'::get_formatted_total( $total )' ); + return self::get_formatted_total( $total ); + } + + /** + * Appends the cart subscription string to a cart total using the @see self::get_cart_subscription_string and then returns it. + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function get_total_ex_tax( $total_ex_tax ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ .'::get_formatted_total_ex_tax( $total_ex_tax )' ); + return self::get_formatted_total_ex_tax( $total_ex_tax ); + } + + /** + * Displays each cart tax in a subscription string and calculates the sign-up fee taxes (if any) + * to display in the string. + * + * @since 1.2 + */ + public static function get_formatted_taxes( $formatted_taxes, $cart ) { + _deprecated_function( __METHOD__, '1.4.9', __CLASS__ .'::get_recurring_tax_totals( $total_ex_tax )' ); + + if ( self::cart_contains_subscription() ) { + + $recurring_taxes = self::get_recurring_taxes(); + + foreach ( $formatted_taxes as $tax_id => $tax_amount ) { + $formatted_taxes[ $tax_id ] = self::get_cart_subscription_string( $tax_amount, $recurring_taxes[ $tax_id ] ); + } + + // Add any recurring tax not already handled - when a subscription has a free trial and a sign-up fee, we get a recurring shipping tax with no initial shipping tax + foreach ( $recurring_taxes as $tax_id => $tax_amount ) { + if ( ! array_key_exists( $tax_id, $formatted_taxes ) ) { + $formatted_taxes[ $tax_id ] = self::get_cart_subscription_string( '', $tax_amount ); + } + } + } + + return $formatted_taxes; + } + + /** + * Checks the cart to see if it contains a subscription product renewal. + * + * Returns the cart_item containing the product renewal, else false. + * + * @deprecated 2.0 + * @since 1.3 + */ + public static function cart_contains_subscription_renewal( $role = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_cart_contains_renewal( $role )' ); + return wcs_cart_contains_renewal( $role ); + } + + /** + * Checks the cart to see if it contains a subscription product renewal. + * + * Returns the cart_item containing the product renewal, else false. + * + * @deprecated 2.0 + * @since 1.4 + */ + public static function cart_contains_failed_renewal_order_payment() { + _deprecated_function( __METHOD__, '2.0', 'wcs_cart_contains_failed_renewal_order_payment()' ); + return wcs_cart_contains_failed_renewal_order_payment(); + } + + + /** + * Restore renewal flag when cart is reset and modify Product object with + * renewal order related info + * + * @since 1.3 + */ + public static function get_cart_item_from_session( $session_data, $values, $key ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::get_cart_item_from_session( $session_data, $values, $key )' ); + } + + /** + * For subscription renewal via cart, use original order discount + * + * @since 1.3 + */ + public static function before_calculate_totals( $cart ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::set_renewal_discounts( $cart )' ); + } + + /** + * For subscription renewal via cart, previously adjust item price by original order discount + * + * No longer required as of 1.3.5 as totals are calculated correctly internally. + * + * @since 1.3 + */ + public static function get_discounted_price_for_renewal( $price, $values, $cart ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::get_discounted_price_for_renewal( $price, $values, $cart )' ); + } + + /** + * Returns a string with the cart discount and subscription period. + * + * @return mixed formatted price or false if there are none + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_formatted_discounts_before_tax( $discount, $cart ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $discount; + } + + /** + * Gets the order discount amount - these are applied after tax + * + * @return mixed formatted price or false if there are none + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_formatted_discounts_after_tax( $discount, $cart ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $discount; + } + + /** + * Returns an individual coupon's formatted discount amount for WooCommerce 2.1+ + * + * @param string $discount_html String of the coupon's discount amount + * @param string $coupon WC_Coupon object for the coupon to which this line item relates + * @return string formatted subscription price string if the cart includes a coupon being applied to recurring amount + * @since 1.4.6 + * @deprecated 2.0 + */ + public static function cart_coupon_discount_amount_html( $discount_html, $coupon ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $discount_html; + } + + /** + * Returns individual coupon's formatted discount amount for WooCommerce 2.1+ + * + * @param string $discount_html String of the coupon's discount amount + * @param string $coupon WC_Coupon object for the coupon to which this line item relates + * @return string formatted subscription price string if the cart includes a coupon being applied to recurring amount + * @since 1.4.6 + * @deprecated 2.0 + */ + public static function cart_totals_fee_html( $cart_totals_fee_html, $fee ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $cart_totals_fee_html; + } + + /** + * Includes the sign-up fee total in the cart total (after calculation). + * + * @since 1.5.10 + * @return string formatted price + * @deprecated 2.0 + */ + public static function get_formatted_cart_total( $cart_contents_total ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $cart_contents_total; + } + + /** + * Includes the sign-up fee subtotal in the subtotal displayed in the cart. + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_formatted_cart_subtotal( $cart_subtotal, $compound, $cart ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $cart_subtotal; + } + + /** + * Returns an array of taxes merged by code, formatted with recurring amount ready for output. + * + * @return array Array of tax_id => tax_amounts for items in the cart + * @since 1.3.5 + * @deprecated 2.0 + */ + public static function get_recurring_tax_totals( $tax_totals, $cart ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return apply_filters( 'woocommerce_cart_recurring_tax_totals', $tax_totals, $cart ); + } + + /** + * Returns a string of the sum of all taxes in the cart for initial payment and + * recurring amount. + * + * @return array Array of tax_id => tax_amounts for items in the cart + * @since 1.4.10 + * @deprecated 2.0 + */ + public static function get_taxes_total_html( $total ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $total; + } + + /** + * Appends the cart subscription string to a cart total using the @see self::get_cart_subscription_string and then returns it. + * + * @return string Formatted subscription price string for the cart total. + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_formatted_total( $total ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $total; + } + + /** + * Appends the cart subscription string to a cart total using the @see self::get_cart_subscription_string and then returns it. + * + * @return string Formatted subscription price string for the cart total. + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_formatted_total_ex_tax( $total_ex_tax ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $total_ex_tax; + } + + /** + * Returns an array of the recurring total fields + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_recurring_totals_fields() { + _deprecated_function( __METHOD__, '2.0', 'recurring total values stored in WC()->cart->recurring_carts' ); + return array(); + } + + /** + * Gets the subscription period from the cart and returns it as an array (eg. array( 'month', 'day' ) ) + * + * Deprecated because a cart can now contain multiple subscription products, so there is no single period for the entire cart. + * + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_cart_subscription_period() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + if ( self::cart_contains_subscription() ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['data']->subscription_period ) ) { + $period = $cart_item['data']->subscription_period; + break; + } elseif ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $period = WC_Subscriptions_Product::get_period( $cart_item['data'] ); + break; + } + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_period', $period ); + } + + /** + * Gets the subscription period from the cart and returns it as an array (eg. array( 'month', 'day' ) ) + * + * Deprecated because a cart can now contain multiple subscription products, so there is no single interval for the entire cart. + * + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_cart_subscription_interval() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $interval = WC_Subscriptions_Product::get_interval( $cart_item['data'] ); + break; + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_interval', $interval ); + } + + /** + * Gets the subscription length from the cart and returns it as an array (eg. array( 'month', 'day' ) ) + * + * Deprecated because a cart can now contain multiple subscription products, so there is no single length for the entire cart. + * + * @since 1.1 + * @deprecated 2.0 + */ + public static function get_cart_subscription_length() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $length = 0; + + if ( self::cart_contains_subscription() ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['data']->subscription_length ) ) { + $length = $cart_item['data']->subscription_length; + break; + } elseif ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $length = WC_Subscriptions_Product::get_length( $cart_item['data'] ); + break; + } + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_length', $length ); + } + + /** + * Gets the subscription length from the cart and returns it as an array (eg. array( 'month', 'day' ) ) + * + * Deprecated because a cart can now contain multiple subscription products, so there is no single trial length for the entire cart. + * + * @since 1.1 + * @deprecated 2.0 + */ + public static function get_cart_subscription_trial_length() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $trial_length = 0; + + if ( self::cart_contains_subscription() ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['data']->subscription_trial_length ) ) { + $trial_length = $cart_item['data']->subscription_trial_length; + break; + } elseif ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $trial_length = WC_Subscriptions_Product::get_trial_length( $cart_item['data'] ); + break; + } + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_trial_length', $trial_length ); + } + + /** + * Gets the subscription trial period from the cart and returns it as an array (eg. array( 'month', 'day' ) ) + * + * Deprecated because a cart can now contain multiple subscription products, so there is no single trial period for the entire cart. + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_cart_subscription_trial_period() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $trial_period = ''; + + // Get the original trial period + if ( self::cart_contains_subscription() ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['data']->subscription_trial_period ) ) { + $trial_period = $cart_item['data']->subscription_trial_period; + break; + } elseif ( WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + $trial_period = WC_Subscriptions_Product::get_trial_period( $cart_item['data'] ); + break; + } + } + } + + return apply_filters( 'woocommerce_subscriptions_cart_trial_period', $trial_period ); + } + + /** + * Get tax row amounts with or without compound taxes includes + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return float price + * @deprecated 2.0 + */ + public static function get_recurring_cart_contents_total() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + if ( ! $cart->prices_include_tax ) { + $recurring_total += $cart->cart_contents_total; + } else { + $recurring_total += $cart->cart_contents_total + $cart->tax_total; + } + } + + return $recurring_total; + } + + /** + * Returns the proportion of cart discount that is recurring for the product specified with $product_id + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring item subtotal amount less tax for items in the cart. + * @since 1.2 + */ + public static function get_recurring_subtotal_ex_tax() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->subtotal_ex_tax; + } + + return $recurring_total; + } + + /** + * Returns the proportion of cart discount that is recurring for the product specified with $product_id + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring item subtotal amount for items in the cart. + * @since 1.2 + */ + public static function get_recurring_subtotal() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->subtotal; + } + + return $recurring_total; + } + + /** + * Returns the proportion of cart discount that is recurring for the product specified with $product_id + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring cart discount amount for items in the cart. + * @since 1.2 + */ + public static function get_recurring_discount_cart() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->discount_cart; + } + + return $recurring_total; + } + + /** + * Returns the cart discount tax amount for WC 2.3 and newer + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double + * @since 2.0 + */ + public static function get_recurring_discount_cart_tax() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->discount_cart_tax; + } + + return $recurring_total; + } + + /** + * Returns the proportion of total discount that is recurring for the product specified with $product_id + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring discount amount for items in the cart. + * @since 1.2 + */ + public static function get_recurring_discount_total() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->discount_total; + } + + return $recurring_total; + } + + /** + * Returns the amount of shipping tax that is recurring. As shipping only applies + * to recurring payments, and only 1 subscription can be purchased at a time, + * this is equal to @see WC_Cart::$shipping_tax_total + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring shipping tax amount for items in the cart. + * @since 1.2 + */ + public static function get_recurring_shipping_tax_total() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->shipping_tax_total; + } + + return $recurring_total; + } + + /** + * Returns the recurring shipping price . As shipping only applies to recurring + * payments, and only 1 subscription can be purchased at a time, this is + * equal to @see WC_Cart::shipping_total + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring shipping amount for items in the cart. + * @since 1.2 + */ + public static function get_recurring_shipping_total() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->shipping_total; + } + + return $recurring_total; + } + + /** + * Returns an array of taxes on an order with their recurring totals. + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return array Array of tax_id => tax_amounts for items in the cart + * @since 1.2 + */ + public static function get_recurring_taxes() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $taxes = array(); + + $recurring_fees = array(); + + foreach ( WC()->cart->recurring_carts as $cart ) { + foreach ( array_keys( $cart->taxes + $cart->shipping_taxes ) as $key ) { + $taxes[ $key ] = ( isset( $cart->shipping_taxes[ $key ] ) ? $cart->shipping_taxes[ $key ] : 0 ) + ( isset( $cart->taxes[ $key ] ) ? $cart->taxes[ $key ] : 0 ); + } + } + + return $taxes; + } + + /** + * Returns an array of recurring fees. + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return array Array of fee_id => fee_details for items in the cart + * @since 1.4.9 + */ + public static function get_recurring_fees() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_fees = array(); + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_fees = array_merge( $recurring_fees, $cart->get_fees() ); + } + + return $recurring_fees; + } + + /** + * Get tax row amounts with or without compound taxes includes + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring tax amount tax for items in the cart (maybe not including compound taxes) + * @since 1.2 + */ + public static function get_recurring_taxes_total( $compound = true ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + foreach ( $cart->taxes as $key => $tax ) { + if ( ! $compound && WC_Tax::is_compound( $key ) ) { continue; } + $recurring_total += $tax; + } + foreach ( $cart->shipping_taxes as $key => $tax ) { + if ( ! $compound && WC_Tax::is_compound( $key ) ) { continue; } + $recurring_total += $tax; + } + } + + return $recurring_total; + } + + /** + * Returns the proportion of total tax on an order that is recurring for the product specified with $product_id + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring tax amount tax for items in the cart. + * @since 1.2 + */ + public static function get_recurring_total_tax() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->tax_total; + } + + return $recurring_total; + } + + /** + * Returns the proportion of total before tax on an order that is recurring for the product specified with $product_id + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring amount less tax for items in the cart. + * @since 1.2 + */ + public static function get_recurring_total_ex_tax() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return self::get_recurring_total() - self::get_recurring_total_tax() - self::get_recurring_shipping_tax_total(); + } + + /** + * Returns the price per period for a subscription in an order. + * + * Deprecated because the cart can now contain subscriptions on multiple billing schedules so there is no one "total" + * + * @return double The total recurring amount for items in the cart. + * @since 1.2 + */ + public static function get_recurring_total() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + $recurring_total = 0; + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total += $cart->get_total(); + } + + return $recurring_total; + } + + /** + * Calculate the total amount of recurring shipping needed. Removes any item from the calculation that + * is not a subscription and calculates the totals. + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function calculate_recurring_shipping() { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + foreach ( WC()->cart->recurring_carts as $cart ) { + $recurring_total = $cart->shipping_total; + } + + return $recurring_total; + } + + /** + * Creates a string representation of the subscription period/term for each item in the cart + * + * @param string $initial_amount The initial amount to be displayed for the subscription as passed through the @see woocommerce_price() function. + * @param float $recurring_amount The price to display in the subscription. + * @param array $args (optional) Flags to customise to display the trial and length of the subscription. Default to false - don't display. + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_cart_subscription_string( $initial_amount, $recurring_amount, $args = array() ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + + if ( ! is_array( $args ) ) { + _deprecated_argument( __CLASS__ . '::' . __FUNCTION__, '1.4', 'Third parameter is now an array of name => value pairs. Use array( "include_lengths" => true ) instead.' ); + $args = array( + 'include_lengths' => $args, + ); + } + + $args = wp_parse_args( $args, array( + 'include_lengths' => false, + 'include_trial' => true, + ) + ); + + $subscription_details = array( + 'initial_amount' => $initial_amount, + 'initial_description' => __( 'now', 'woocommerce-subscriptions' ), + 'recurring_amount' => $recurring_amount, + 'subscription_interval' => self::get_cart_subscription_interval(), + 'subscription_period' => self::get_cart_subscription_period(), + 'trial_length' => self::get_cart_subscription_trial_length(), + 'trial_period' => self::get_cart_subscription_trial_period(), + ); + + $is_one_payment = ( self::get_cart_subscription_length() > 0 && self::get_cart_subscription_length() == self::get_cart_subscription_interval() ) ? true : false; + + // Override defaults when subscription is for one billing period + if ( $is_one_payment ) { + + $subscription_details['subscription_length'] = self::get_cart_subscription_length(); + + } else { + + if ( true === $args['include_lengths'] ) { + $subscription_details['subscription_length'] = self::get_cart_subscription_length(); + } + + if ( false === $args['include_trial'] ) { + $subscription_details['trial_length'] = 0; + } + } + + $initial_amount_string = ( is_numeric( $subscription_details['initial_amount'] ) ) ? wc_price( $subscription_details['initial_amount'] ) : $subscription_details['initial_amount']; + $recurring_amount_string = ( is_numeric( $subscription_details['recurring_amount'] ) ) ? wc_price( $subscription_details['recurring_amount'] ) : $subscription_details['recurring_amount']; + + // Don't show up front fees when there is no trial period and no sign up fee and they are the same as the recurring amount + if ( self::get_cart_subscription_trial_length() == 0 && self::get_cart_subscription_sign_up_fee() == 0 && $initial_amount_string == $recurring_amount_string ) { + $subscription_details['initial_amount'] = ''; + } elseif ( wc_price( 0 ) == $initial_amount_string && false === $is_one_payment && self::get_cart_subscription_trial_length() > 0 ) { // don't show $0.00 initial amount (i.e. a free trial with no non-subscription products in the cart) unless the recurring period is the same as the billing period + $subscription_details['initial_amount'] = ''; + } + + // Include details of a synced subscription in the cart + if ( $synchronised_cart_item = WC_Subscriptions_Synchroniser::cart_contains_synced_subscription() ) { + $subscription_details += array( + 'is_synced' => true, + 'synchronised_payment_day' => WC_Subscriptions_Synchroniser::get_products_payment_day( $synchronised_cart_item['data'] ), + ); + } + + $subscription_details = apply_filters( 'woocommerce_cart_subscription_string_details', $subscription_details, $args ); + + $subscription_string = wcs_price_string( $subscription_details ); + + return $subscription_string; + } + + /** + * Uses the a subscription's combined price total calculated by WooCommerce to determine the + * total price that should be charged per period. + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function set_calculated_total( $total ) { + _deprecated_function( __METHOD__, '2.0', 'values from WC()->cart->recurring_carts' ); + return $total; + } + + /** + * Get the recurring amounts values from the session + * + * @since 1.0 + */ + public static function get_cart_from_session() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Store the sign-up fee cart values in the session + * + * @since 1.0 + */ + public static function set_session() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Reset the sign-up fee fields in the current session + * + * @since 1.0 + */ + public static function reset() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Returns a cart item's product ID. For a variation, this will be a variation ID, for a simple product, + * it will be the product's ID. + * + * @since 1.5 + */ + public static function get_items_product_id( $cart_item ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_canonical_product_id( $cart_item )' ); + return wcs_get_canonical_product_id( $cart_item ); + } + + /** + * Store how much discount each coupon grants. + * + * @param mixed $code + * @param mixed $amount + * @return void + */ + public static function increase_coupon_discount_amount( $code, $amount ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions_Coupon::increase_coupon_discount_amount( WC()->cart, $code, $amount )' ); + + if ( empty( WC()->cart->coupon_discount_amounts[ $code ] ) ) { + WC()->cart->coupon_discount_amounts[ $code ] = 0; + } + + if ( 'recurring_total' != self::$calculation_type ) { + WC()->cart->coupon_discount_amounts[ $code ] += $amount; + } + } + + /** + * Don't display shipping prices if the initial order won't require shipping (i.e. all the products in the cart are subscriptions with a free trial or synchronised to a date in the future) + * + * @return string Label for a shipping method + * @since 1.3 + */ + public static function get_cart_shipping_method_full_label( $label, $method ) { + _deprecated_function( __METHOD__, '2.0.12' ); + + if ( ! self::charge_shipping_up_front() ) { + $label = $method->label; + } + + return $label; + } + + /** + * One time shipping can null the need for shipping needs. WooCommerce treats that as no need to ship, therefore it will call + * WC()->shipping->reset() on it, which will wipe the preferences saved. That can cause the chosen shipping method for the one + * time shipping feature to be lost, and the first default to be applied instead. To counter that, we save the chosen shipping + * method to a key that's not going to get wiped by WC's method, and then later restore it. + */ + public static function maybe_restore_chosen_shipping_method() { + $chosen_shipping_method_cache = WC()->session->get( 'wcs_shipping_methods', false ); + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', array() ); + + if ( false !== $chosen_shipping_method_cache && empty( $chosen_shipping_methods ) ) { + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_method_cache ); + } + } +} +WC_Subscriptions_Cart::init(); diff --git a/includes/class-wc-subscriptions-change-payment-gateway.php b/includes/class-wc-subscriptions-change-payment-gateway.php new file mode 100644 index 0000000..cca91b2 --- /dev/null +++ b/includes/class-wc-subscriptions-change-payment-gateway.php @@ -0,0 +1,592 @@ + 0 ) { + self::$woocommerce_messages = wc_get_notices( 'success' ); + self::$woocommerce_messages += wc_get_notices( 'notice' ); + } + + if ( wc_notice_count( 'error' ) > 0 ) { + self::$woocommerce_errors = wc_get_notices( 'error' ); + } + } + + /** + * If requesting a payment method change, replace the woocommerce_pay_shortcode() with a change payment form. + * + * @since 1.4 + */ + public static function maybe_replace_pay_shortcode() { + global $wp; + $valid_request = false; + + // if the request to pay for the order belongs to a subscription but there's no GET params for changing payment method, show receipt page. + if ( ! self::$is_request_to_change_payment && isset( $wp->query_vars['order-pay'] ) && wcs_is_subscription( absint( $wp->query_vars['order-pay'] ) ) ) { + + $valid_request = true; + + ob_clean(); + + do_action( 'before_woocommerce_pay' ); + + $subscription_key = isset( $_GET['key'] ) ? wc_clean( $_GET['key'] ) : ''; + $subscription = wcs_get_subscription( absint( $wp->query_vars['order-pay'] ) ); + + if ( $subscription->id == absint( $wp->query_vars['order-pay'] ) && $subscription->order_key == $subscription_key ) { + + ?> +
    +
      +
    • + tags + echo wp_kses( sprintf( esc_html__( 'Subscription Number: %s', 'woocommerce-subscriptions' ), '' . esc_html( $subscription->get_order_number() ) . '' ), array( 'strong' => true ) ); + ?> +
    • +
    • + tags + echo wp_kses( sprintf( esc_html__( 'Next Payment Date: %s', 'woocommerce-subscriptions' ), '' . esc_html( $subscription->get_date_to_display( 'next_payment' ) ) . '' ), array( 'strong' => true ) ); + ?> +
    • +
    • + tags + echo wp_kses_post( sprintf( esc_html__( 'Total: %s', 'woocommerce-subscriptions' ), '' . $subscription->get_formatted_order_total() . '' ) ); + ?> +
    • + payment_method_title ) : ?> +
    • + ' . esc_html( $subscription->get_payment_method_to_display() ) . '' ), array( 'strong' => true ) ); + ?> +
    • + +
    + + payment_method, $subscription->id ); ?> + +
    + '; + + if ( ! empty( self::$woocommerce_errors ) ) { + foreach ( self::$woocommerce_errors as $error ) { + WC_Subscriptions::add_notice( $error, 'error' ); + } + } + + if ( ! empty( self::$woocommerce_messages ) ) { + foreach ( self::$woocommerce_messages as $message ) { + WC_Subscriptions::add_notice( $message, 'success' ); + } + } + + $subscription = wcs_get_subscription( absint( $_GET['change_payment_method'] ) ); + + if ( wp_verify_nonce( $_GET['_wpnonce'], __FILE__ ) === false ) { + + WC_Subscriptions::add_notice( __( 'There was an error with your request. Please try again.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( empty( $subscription ) ) { + + WC_Subscriptions::add_notice( __( 'Invalid Subscription.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! current_user_can( 'edit_shop_subscription_payment_method', $subscription->id ) ) { + + WC_Subscriptions::add_notice( __( 'That doesn\'t appear to be one of your subscriptions.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! $subscription->can_be_updated_to( 'new-payment-method' ) ) { + + WC_Subscriptions::add_notice( __( 'The payment method can not be changed for that subscription.', 'woocommerce-subscriptions' ), 'error' ); + + } else { + + if ( $subscription->get_time( 'next_payment' ) > 0 ) { + // translators: placeholder is next payment's date + $next_payment_string = sprintf( __( ' Next payment is due %s.', 'woocommerce-subscriptions' ), $subscription->get_date_to_display( 'next_payment' ) ); + } else { + $next_payment_string = ''; + } + + // translators: placeholder is either empty or "Next payment is due..." + WC_Subscriptions::add_notice( sprintf( __( 'Choose a new payment method.%s', 'woocommerce-subscriptions' ), $next_payment_string ), 'notice' ); + WC_Subscriptions::print_notices(); + + if ( $subscription->order_key == $_GET['key'] ) { + + // Set customer location to order location + if ( $subscription->billing_country ) { + WC()->customer->set_country( $subscription->billing_country ); + } + if ( $subscription->billing_state ) { + WC()->customer->set_state( $subscription->billing_state ); + } + if ( $subscription->billing_postcode ) { + WC()->customer->set_postcode( $subscription->billing_postcode ); + } + + wc_get_template( 'checkout/form-change-payment-method.php', array( 'subscription' => $subscription ), '', plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + + $valid_request = true; + + } else { + + WC_Subscriptions::add_notice( __( 'Invalid order.', 'woocommerce-subscriptions' ), 'error' ); + + } + } + } + + if ( false === $valid_request ) { + WC_Subscriptions::print_notices(); + } + } + + /** + * Add a "Change Payment Method" button to the "My Subscriptions" table. + * + * @param array $all_actions The $subscription_key => $actions array with all actions that will be displayed for a subscription on the "My Subscriptions" table + * @param array $subscriptions All of a given users subscriptions that will be displayed on the "My Subscriptions" table + * @since 1.4 + */ + public static function change_payment_method_button( $actions, $subscription ) { + + if ( $subscription->can_be_updated_to( 'new-payment-method' ) ) { + + $actions['change_payment_method'] = array( + 'url' => wp_nonce_url( add_query_arg( array( 'change_payment_method' => $subscription->id ), $subscription->get_checkout_payment_url() ), __FILE__ ), + 'name' => _x( 'Change Payment', 'label on button, imperative', 'woocommerce-subscriptions' ), + ); + + } + + return $actions; + } + + /** + * Process the change payment form. + * + * Based on the @see woocommerce_pay_action() function. + * + * @access public + * @return void + * @since 1.4 + */ + public static function change_payment_method_via_pay_shortcode() { + + if ( isset( $_POST['_wcsnonce'] ) && wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_change_payment_method' ) ) { + + $subscription = wcs_get_subscription( absint( $_POST['woocommerce_change_payment'] ) ); + + do_action( 'woocommerce_subscription_change_payment_method_via_pay_shortcode', $subscription ); + + ob_start(); + + if ( $subscription->order_key == $_GET['key'] ) { + + // Set customer location to order location + if ( $subscription->billing_country ) { + WC()->customer->set_country( $subscription->billing_country ); + } + if ( $subscription->billing_state ) { + WC()->customer->set_state( $subscription->billing_state ); + } + if ( $subscription->billing_postcode ) { + WC()->customer->set_postcode( $subscription->billing_postcode ); + } + if ( $subscription->billing_city ) { + WC()->customer->set_city( $subscription->billing_city ); + } + + // Update payment method + $new_payment_method = wc_clean( $_POST['payment_method'] ); + + // Allow some payment gateways which can't process the payment immediately, like PayPal, to do it later after the payment/sign-up is confirmed + if ( apply_filters( 'woocommerce_subscriptions_update_payment_via_pay_shortcode', true, $new_payment_method, $subscription ) ) { + self::update_payment_method( $subscription, $new_payment_method ); + } + + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + + // Validate + $available_gateways[ $new_payment_method ]->validate_fields(); + + // Process payment for the new method (with a $0 order total) + if ( wc_notice_count( 'error' ) == 0 ) { + + $result = $available_gateways[ $new_payment_method ]->process_payment( $subscription->id ); + + if ( 'success' == $result['result'] && wc_get_page_permalink( 'myaccount' ) == $result['redirect'] ) { + $result['redirect'] = $subscription->get_view_order_url(); + } + + $result = apply_filters( 'woocommerce_subscriptions_process_payment_for_change_method_via_pay_shortcode', $result, $subscription ); + + // Redirect to success/confirmation/payment page + if ( 'success' == $result['result'] ) { + WC_Subscriptions::add_notice( __( 'Payment method updated.', 'woocommerce-subscriptions' ), 'success' ); + wp_redirect( $result['redirect'] ); + exit; + } + } + } + } + } + + /** + * Update the recurring payment method on a subscription order. + * + * @param array $available_gateways The payment gateways which are currently being allowed. + * @since 1.4 + */ + public static function update_payment_method( $subscription, $new_payment_method ) { + + $old_payment_method = $subscription->payment_method; + $old_payment_method_title = $subscription->payment_method_title; + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); // Also inits all payment gateways to make sure that hooks are attached correctly + + do_action( 'woocommerce_subscriptions_pre_update_payment_method', $subscription, $new_payment_method, $old_payment_method ); + + // Make sure the subscription is cancelled with the current gateway + WC_Subscriptions_Payment_Gateways::trigger_gateway_status_updated_hook( $subscription, 'cancelled' ); + + // Update meta + update_post_meta( $subscription->id, '_old_payment_method', $old_payment_method ); + update_post_meta( $subscription->id, '_payment_method', $new_payment_method ); + + if ( isset( $available_gateways[ $new_payment_method ] ) ) { + $new_payment_method_title = $available_gateways[ $new_payment_method ]->get_title(); + } else { + $new_payment_method_title = ''; + } + + update_post_meta( $subscription->id, '_old_payment_method_title', $old_payment_method_title ); + update_post_meta( $subscription->id, '_payment_method_title', $new_payment_method_title ); + + if ( empty( $old_payment_method_title ) ) { + $old_payment_method_title = $old_payment_method; + } + + if ( empty( $new_payment_method_title ) ) { + $new_payment_method_title = $new_payment_method; + } + + // Log change on order + $subscription->add_order_note( sprintf( _x( 'Payment method changed from "%1$s" to "%2$s" by the subscriber from their account page.', '%1$s: old payment title, %2$s: new payment title', 'woocommerce-subscriptions' ), $old_payment_method_title, $new_payment_method_title ) ); + + do_action( 'woocommerce_subscription_payment_method_updated', $subscription, $new_payment_method, $old_payment_method ); + do_action( 'woocommerce_subscription_payment_method_updated_to_' . $new_payment_method, $subscription, $old_payment_method ); + do_action( 'woocommerce_subscription_payment_method_updated_from_' . $old_payment_method, $subscription, $new_payment_method ); + } + + /** + * Only display gateways which support changing payment method when paying for a failed renewal order or + * when requesting to change the payment method. + * + * @param array $available_gateways The payment gateways which are currently being allowed. + * @since 1.4 + */ + public static function get_available_payment_gateways( $available_gateways ) { + + if ( isset( $_GET['change_payment_method'] ) || wcs_cart_contains_failed_renewal_order_payment() ) { + foreach ( $available_gateways as $gateway_id => $gateway ) { + if ( true !== $gateway->supports( 'subscription_payment_method_change_customer' ) ) { + unset( $available_gateways[ $gateway_id ] ); + } + } + } + + return $available_gateways; + } + + /** + * Make sure certain totals are set to 0 when the request is to change the payment method without charging anything. + * + * @since 1.4 + */ + public static function maybe_zero_total( $total, $subscription ) { + global $wp; + + if ( ! empty( $_POST['_wcsnonce'] ) && wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_change_payment_method' ) && isset( $_POST['woocommerce_change_payment'] ) && $subscription->order_key == $_GET['key'] && $subscription->id == absint( $_POST['woocommerce_change_payment'] ) ) { + $total = 0; + } elseif ( ! self::$is_request_to_change_payment && isset( $wp->query_vars['order-pay'] ) && wcs_is_subscription( absint( $wp->query_vars['order-pay'] ) ) ) { + // if the request to pay for the order belongs to a subscription but there's no GET params for changing payment method, the receipt page is being used to collect credit card details so we still need to $0 the total + $total = 0; + } + + return $total; + } + + /** + * Redirect back to the "My Account" page instead of the "Thank You" page after changing the payment method. + * + * @since 1.4 + */ + public static function get_return_url( $return_url ) { + + if ( ! empty( $_POST['_wcsnonce'] ) && wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_change_payment_method' ) && isset( $_POST['woocommerce_change_payment'] ) ) { + $return_url = get_permalink( wc_get_page_id( 'myaccount' ) ); + } + + return $return_url; + } + + /** + * Update the recurring payment method for a subscription after a customer has paid for a failed renewal order + * (which usually failed because of an issue with the existing payment, like an expired card or token). + * + * Also trigger a hook for payment gateways to update any meta on the original order for a subscription. + * + * @param WC_Order $renewal_order The order which recorded the successful payment (to make up for the failed automatic payment). + * @param WC_Order $original_order The original order in which the subscription was purchased. + * @since 1.4 + */ + public static function change_failing_payment_method( $renewal_order, $subscription ) { + + if ( ! $subscription->is_manual() ) { + + if ( ! empty( $_POST['_wcsnonce'] ) && wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_change_payment_method' ) && isset( $_POST['payment_method'] ) ) { + $new_payment_method = wc_clean( $_POST['payment_method'] ); + } else { + $new_payment_method = $renewal_order->payment_method; + } + + self::update_payment_method( $subscription, $new_payment_method ); + + do_action( 'woocommerce_subscription_failing_payment_method_updated', $subscription, $renewal_order ); + do_action( 'woocommerce_subscription_failing_payment_method_updated_' . $new_payment_method, $subscription, $renewal_order ); + } + } + + /** + * Add a 'new-payment-method' handler to the @see WC_Subscription::can_be_updated_to() function + * to determine whether the recurring payment method on a subscription can be changed. + * + * For the recurring payment method to be changeable, the subscription must be active, have future (automatic) payments + * and use a payment gateway which allows the subscription to be cancelled. + * + * @param bool $subscription_can_be_changed Flag of whether the subscription can be changed to + * @param string $new_status_or_meta The status or meta data you want to change th subscription to. Can be 'active', 'on-hold', 'cancelled', 'expired', 'trash', 'deleted', 'failed', 'new-payment-date' or some other value attached to the 'woocommerce_can_subscription_be_changed_to' filter. + * @param object $args Set of values used in @see WC_Subscriptions_Manager::can_subscription_be_changed_to() for determining if a subscription can be changes, include: + * 'subscription_key' string A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * 'subscription' array Subscription of the form returned by @see WC_Subscriptions_Manager::get_subscription() + * 'user_id' int The ID of the subscriber. + * 'order' WC_Order The order which recorded the successful payment (to make up for the failed automatic payment). + * 'payment_gateway' WC_Payment_Gateway The subscription's recurring payment gateway + * 'order_uses_manual_payments' bool A boolean flag indicating whether the subscription requires manual renewal payment. + * @since 1.4 + */ + public static function can_subscription_be_updated_to_new_payment_method( $subscription_can_be_changed, $subscription ) { + + if ( WC_Subscriptions_Payment_Gateways::one_gateway_supports( 'subscription_payment_method_change_customer' ) && $subscription->get_time( 'next_payment' ) > 0 && ! $subscription->is_manual() && $subscription->payment_method_supports( 'subscription_cancellation' ) && $subscription->has_status( 'active' ) ) { + $subscription_can_be_changed = true; + } else { + $subscription_can_be_changed = false; + } + + return $subscription_can_be_changed; + } + + /** + * Replace a page title with the endpoint title + * + * @param string $title + * @return string + * @since 2.0 + */ + public static function change_payment_method_page_title( $title ) { + + if ( is_main_query() && in_the_loop() && is_page() && is_checkout_pay_page() && self::$is_request_to_change_payment ) { + $title = _x( 'Change Payment Method', 'the page title of the change payment method form', 'woocommerce-subscriptions' ); + } + + return $title; + } + + /** + * When processing a change_payment_method request on a subscription that has a failed or pending renewal, + * we don't want the `$order->needs_payment()` check inside WC_Shortcode_Checkout::order_pay() to pass. + * This is causing `$gateway->payment_fields()` to be called multiple times. + * + * @param bool $needs_payment + * @param WC_Subscription $subscription + * @return bool + * @since 2.0.7 + */ + public static function maybe_override_needs_payment( $needs_payment ) { + + if ( $needs_payment && self::$is_request_to_change_payment ) { + $needs_payment = false; + } + + return $needs_payment; + } + + /** Deprecated Functions **/ + + /** + * Update the recurring payment method on a subscription order. + * + * @param array $available_gateways The payment gateways which are currently being allowed. + * @since 1.4 + * @deprecated 2.0 + */ + public static function update_recurring_payment_method( $subscription_key, $order, $new_payment_method ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::update_payment_method()' ); + self::update_payment_method( wcs_get_subscription_from_key( $subscription_key ), $new_payment_method ); + } + + /** + * Keep a record of an order's dates if we're marking it as completed during a request to change the payment method. + * + * Deprecated as we now operate on a WC_Subscription object instead of the parent order, so we don't need to hack around date changes. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function store_original_order_dates( $new_order_status, $subscription_id ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Restore an order's dates if we marked it as completed during a request to change the payment method. + * + * Deprecated as we now operate on a WC_Subscription object instead of the parent order, so we don't need to hack around date changes. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function restore_original_order_dates( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Add a 'new-payment-method' handler to the @see WC_Subscription::can_be_updated_to() function + * to determine whether the recurring payment method on a subscription can be changed. + * + * For the recurring payment method to be changeable, the subscription must be active, have future (automatic) payments + * and use a payment gateway which allows the subscription to be cancelled. + * + * @param bool $subscription_can_be_changed Flag of whether the subscription can be changed to + * @param string $new_status_or_meta The status or meta data you want to change th subscription to. Can be 'active', 'on-hold', 'cancelled', 'expired', 'trash', 'deleted', 'failed', 'new-payment-date' or some other value attached to the 'woocommerce_can_subscription_be_changed_to' filter. + * @param object $args Set of values used in @see WC_Subscriptions_Manager::can_subscription_be_changed_to() for determining if a subscription can be changes, include: + * 'subscription_key' string A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * 'subscription' array Subscription of the form returned by @see WC_Subscriptions_Manager::get_subscription() + * 'user_id' int The ID of the subscriber. + * 'order' WC_Order The order which recorded the successful payment (to make up for the failed automatic payment). + * 'payment_gateway' WC_Payment_Gateway The subscription's recurring payment gateway + * 'order_uses_manual_payments' bool A boolean flag indicating whether the subscription requires manual renewal payment. + * @since 1.4 + */ + public static function can_subscription_be_changed_to( $subscription_can_be_changed, $new_status_or_meta, $args ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::can_subscription_be_updated_to_new_payment_method()' ); + + if ( 'new-payment-method' === $new_status_or_meta ) { + $subscription_can_be_changed = wcs_get_subscription_from_key( $args->subscription_key )->can_be_updated_to( 'new-payment-method' ); + } + + return $subscription_can_be_changed; + } +} +WC_Subscriptions_Change_Payment_Gateway::init(); diff --git a/includes/class-wc-subscriptions-checkout.php b/includes/class-wc-subscriptions-checkout.php new file mode 100644 index 0000000..d4cf8d5 --- /dev/null +++ b/includes/class-wc-subscriptions-checkout.php @@ -0,0 +1,437 @@ +id, array( 'order_type' => 'parent' ) ); + + if ( ! empty( $subscriptions ) ) { + remove_action( 'before_delete_post', 'WC_Subscriptions_Manager::maybe_cancel_subscription' ); + foreach ( $subscriptions as $subscription ) { + wp_delete_post( $subscription->id ); + } + add_action( 'before_delete_post', 'WC_Subscriptions_Manager::maybe_cancel_subscription' ); + } + + WC_Subscriptions_Cart::set_global_recurring_shipping_packages(); + + // Create new subscriptions for each group of subscription products in the cart (that is not a renewal) + foreach ( WC()->cart->recurring_carts as $recurring_cart ) { + + $subscription = self::create_subscription( $order, $recurring_cart ); // Exceptions are caught by WooCommerce + + if ( is_wp_error( $subscription ) ) { + throw new Exception( $subscription->get_error_message() ); + } + + do_action( 'woocommerce_checkout_subscription_created', $subscription, $order, $recurring_cart ); + } + + do_action( 'subscriptions_created_for_order', $order ); // Backward compatibility + } + + /** + * Create a new subscription from a cart item on checkout. + * + * The function doesn't validate whether the cart item is a subscription product, meaning it can be used for any cart item, + * but the item will need a `subscription_period` and `subscription_period_interval` value set on it, at a minimum. + * + * @param WC_Order $order + * @param WC_Cart $cart + * @since 2.0 + */ + public static function create_subscription( $order, $cart ) { + global $wpdb; + + try { + // Start transaction if available + $wpdb->query( 'START TRANSACTION' ); + + // Set the recurring line totals on the subscription + $variation_id = wcs_cart_pluck( $cart, 'variation_id' ); + $product_id = empty( $variation_id ) ? wcs_cart_pluck( $cart, 'product_id' ) : $variation_id; + + // We need to use the $order->order_date value because the post_date_gmt isn't always set + $order_date_gmt = get_gmt_from_date( $order->order_date ); + + $subscription = wcs_create_subscription( array( + 'start_date' => $cart->start_date, + 'order_id' => $order->id, + 'customer_id' => $order->get_user_id(), + 'billing_period' => wcs_cart_pluck( $cart, 'subscription_period' ), + 'billing_interval' => wcs_cart_pluck( $cart, 'subscription_period_interval' ), + 'customer_note' => $order->customer_note, + ) ); + + if ( is_wp_error( $subscription ) ) { + throw new Exception( $subscription->get_error_message() ); + } + + // Set the subscription's billing and shipping address + $subscription = wcs_copy_order_address( $order, $subscription ); + + $subscription->update_dates( array( + 'trial_end' => $cart->trial_end_date, + 'next_payment' => $cart->next_payment_date, + 'end' => $cart->end_date, + ) ); + + // Store trial period for PayPal + if ( wcs_cart_pluck( $cart, 'subscription_trial_length' ) > 0 ) { + update_post_meta( $subscription->id, '_trial_period', wcs_cart_pluck( $cart, 'subscription_trial_period' ) ); + } + + // Set the payment method on the subscription + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + + if ( $cart->needs_payment() && isset( $available_gateways[ $order->payment_method ] ) ) { + $subscription->set_payment_method( $available_gateways[ $order->payment_method ] ); + } + + if ( ! $cart->needs_payment() || 'yes' == get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) ) { + $subscription->update_manual( 'true' ); + } elseif ( ! isset( $available_gateways[ $order->payment_method ] ) || ! $available_gateways[ $order->payment_method ]->supports( 'subscriptions' ) ) { + $subscription->update_manual( 'true' ); + } + + wcs_copy_order_meta( $order, $subscription, 'subscription' ); + + // Store the line items + foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { + $item_id = self::add_cart_item( $subscription, $cart_item, $cart_item_key ); + } + + // Store fees (although no fees recur by default, extensions may add them) + foreach ( $cart->get_fees() as $fee_key => $fee ) { + $item_id = $subscription->add_fee( $fee ); + + if ( ! $item_id ) { + // translators: placeholder is an internal error number + throw new Exception( sprintf( __( 'Error %d: Unable to create subscription. Please try again.', 'woocommerce-subscriptions' ), 403 ) ); + } + + // Allow plugins to add order item meta to fees + do_action( 'woocommerce_add_order_fee_meta', $order->id, $item_id, $fee, $fee_key ); + } + + self::add_shipping( $subscription, $cart ); + + // Store tax rows + foreach ( array_keys( $cart->taxes + $cart->shipping_taxes ) as $tax_rate_id ) { + if ( $tax_rate_id && ! $subscription->add_tax( $tax_rate_id, $cart->get_tax_amount( $tax_rate_id ), $cart->get_shipping_tax_amount( $tax_rate_id ) ) && apply_filters( 'woocommerce_cart_remove_taxes_zero_rate_id', 'zero-rated' ) !== $tax_rate_id ) { + // translators: placeholder is an internal error number + throw new Exception( sprintf( __( 'Error %d: Unable to add tax to subscription. Please try again.', 'woocommerce-subscriptions' ), 405 ) ); + } + } + + // Store coupons + foreach ( $cart->get_coupons() as $code => $coupon ) { + if ( ! $subscription->add_coupon( $code, $cart->get_coupon_discount_amount( $code ), $cart->get_coupon_discount_tax_amount( $code ) ) ) { + // translators: placeholder is an internal error number + throw new Exception( sprintf( __( 'Error %d: Unable to create order. Please try again.', 'woocommerce-subscriptions' ), 406 ) ); + } + } + + // Set the recurring totals on the subscription + $subscription->set_total( $cart->shipping_total, 'shipping' ); + $subscription->set_total( $cart->get_cart_discount_total(), 'cart_discount' ); + $subscription->set_total( $cart->get_cart_discount_tax_total(), 'cart_discount_tax' ); + $subscription->set_total( $cart->tax_total, 'tax' ); + $subscription->set_total( $cart->shipping_tax_total, 'shipping_tax' ); + $subscription->set_total( $cart->total ); + + // If we got here, the subscription was created without problems + $wpdb->query( 'COMMIT' ); + + } catch ( Exception $e ) { + // There was an error adding the subscription + $wpdb->query( 'ROLLBACK' ); + return new WP_Error( 'checkout-error', $e->getMessage() ); + } + + return $subscription; + } + + + /** + * Stores shipping info on the subscription + * + * @param WC_Subscription $subscription instance of a subscriptions object + * @param WC_Cart $cart A cart with recurring items in it + */ + public static function add_shipping( $subscription, $cart ) { + + // We need to make sure we only get recurring shipping packages + WC_Subscriptions_Cart::set_calculation_type( 'recurring_total' ); + + foreach ( $cart->get_shipping_packages() as $package_index => $base_package ) { + + $package = WC()->shipping->calculate_shipping_for_package( $base_package ); + + $recurring_shipping_package_key = WC_Subscriptions_Cart::get_recurring_shipping_package_key( $cart->recurring_cart_key, $package_index ); + + $shipping_method_id = isset( WC()->checkout()->shipping_methods[ $package_index ] ) ? WC()->checkout()->shipping_methods[ $package_index ] : ''; + + if ( isset( WC()->checkout()->shipping_methods[ $recurring_shipping_package_key ] ) ) { + $shipping_method_id = WC()->checkout()->shipping_methods[ $recurring_shipping_package_key ]; + $package_key = $recurring_shipping_package_key; + } else { + $package_key = $package_index; + } + + if ( isset( $package['rates'][ $shipping_method_id ] ) ) { + + $item_id = $subscription->add_shipping( $package['rates'][ $shipping_method_id ] ); + + if ( ! $item_id ) { + throw new Exception( __( 'Error: Unable to create subscription. Please try again.', 'woocommerce-subscriptions' ) ); + } + + // Allows plugins to add order item meta to shipping + do_action( 'woocommerce_add_shipping_order_item', $subscription->id, $item_id, $package_key ); + do_action( 'woocommerce_subscriptions_add_recurring_shipping_order_item', $subscription->id, $item_id, $package_key ); + } + } + + WC_Subscriptions_Cart::set_calculation_type( 'none' ); + } + + /** + * Add a cart item to a subscription. + * + * @since 2.0 + */ + public static function add_cart_item( $subscription, $cart_item, $cart_item_key ) { + $item_id = $subscription->add_product( + $cart_item['data'], + $cart_item['quantity'], + array( + 'variation' => $cart_item['variation'], + 'totals' => array( + 'subtotal' => $cart_item['line_subtotal'], + 'subtotal_tax' => $cart_item['line_subtotal_tax'], + 'total' => $cart_item['line_total'], + 'tax' => $cart_item['line_tax'], + 'tax_data' => $cart_item['line_tax_data'], + ), + ) + ); + + if ( ! $item_id ) { + // translators: placeholder is an internal error number + throw new Exception( sprintf( __( 'Error %d: Unable to create subscription. Please try again.', 'woocommerce-subscriptions' ), 402 ) ); + } + + $cart_item_product_id = ( 0 != $cart_item['variation_id'] ) ? $cart_item['variation_id'] : $cart_item['product_id']; + + if ( WC_Subscriptions_Product::get_trial_length( wcs_get_canonical_product_id( $cart_item ) ) > 0 ) { + wc_add_order_item_meta( $item_id, '_has_trial', 'true' ); + } + + // Allow plugins to add order item meta + do_action( 'woocommerce_add_order_item_meta', $item_id, $cart_item, $cart_item_key ); + + do_action( 'woocommerce_add_subscription_item_meta', $item_id, $cart_item, $cart_item_key ); + + return $item_id; + } + + /** + * When a new order is inserted, add subscriptions related order meta. + * + * @since 1.0 + */ + public static function add_order_meta( $order_id, $posted ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Add each subscription product's details to an order so that the state of the subscription persists even when a product is changed + * + * @since 1.2.5 + */ + public static function add_order_item_meta( $item_id, $values ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * If shopping cart contains subscriptions, make sure a user can register on the checkout page + * + * @since 1.0 + */ + public static function make_checkout_registration_possible( $checkout = '' ) { + + if ( WC_Subscriptions_Cart::cart_contains_subscription() && ! is_user_logged_in() ) { + + // Make sure users can sign up + if ( false === $checkout->enable_signup ) { + $checkout->enable_signup = true; + self::$signup_option_changed = true; + } + + // Make sure users are required to register an account + if ( true === $checkout->enable_guest_checkout ) { + $checkout->enable_guest_checkout = false; + self::$guest_checkout_option_changed = true; + + if ( ! is_user_logged_in() ) { + $checkout->must_create_account = true; + } + } + } + + } + + /** + * Make sure account fields display the required "*" when they are required. + * + * @since 1.3.5 + */ + public static function make_checkout_account_fields_required( $checkout_fields ) { + + if ( WC_Subscriptions_Cart::cart_contains_subscription() && ! is_user_logged_in() ) { + + $account_fields = array( + 'account_username', + 'account_password', + 'account_password-2', + ); + + foreach ( $account_fields as $account_field ) { + if ( isset( $checkout_fields['account'][ $account_field ] ) ) { + $checkout_fields['account'][ $account_field ]['required'] = true; + } + } + } + + return $checkout_fields; + } + + /** + * After displaying the checkout form, restore the store's original registration settings. + * + * @since 1.1 + */ + public static function restore_checkout_registration_settings( $checkout = '' ) { + + if ( self::$signup_option_changed ) { + $checkout->enable_signup = false; + } + + if ( self::$guest_checkout_option_changed ) { + $checkout->enable_guest_checkout = true; + if ( ! is_user_logged_in() ) { // Also changed must_create_account + $checkout->must_create_account = false; + } + } + } + + /** + * Also make sure the guest checkout option value passed to the woocommerce.js forces registration. + * Otherwise the registration form is hidden by woocommerce.js. + * + * @since 1.1 + */ + public static function filter_woocommerce_script_paramaters( $woocommerce_params ) { + + if ( WC_Subscriptions_Cart::cart_contains_subscription() && ! is_user_logged_in() && isset( $woocommerce_params['option_guest_checkout'] ) && 'yes' == $woocommerce_params['option_guest_checkout'] ) { + $woocommerce_params['option_guest_checkout'] = 'no'; + } + + return $woocommerce_params; + } + + /** + * During the checkout process, force registration when the cart contains a subscription. + * + * @since 1.1 + */ + public static function force_registration_during_checkout( $woocommerce_params ) { + + if ( WC_Subscriptions_Cart::cart_contains_subscription() && ! is_user_logged_in() ) { + $_POST['createaccount'] = 1; + } + + } + + /** + * When creating an order at checkout, if the checkout is to renew a subscription from a failed + * payment, hijack the order creation to make a renewal order - not a plain WooCommerce order. + * + * @since 1.3 + * @deprecated 2.0 + */ + public static function filter_woocommerce_create_order( $order_id, $checkout_object ) { + _deprecated_function( __METHOD__, '2.0' ); + return $order_id; + } + + /** + * Customise which actions are shown against a subscriptions order on the My Account page. + * + * @since 1.3 + */ + public static function filter_woocommerce_my_account_my_orders_actions( $actions, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::filter_my_account_my_orders_actions()' ); + return $actions; + } +} + +WC_Subscriptions_Checkout::init(); diff --git a/includes/class-wc-subscriptions-coupon.php b/includes/class-wc-subscriptions-coupon.php new file mode 100644 index 0000000..b4f7b3c --- /dev/null +++ b/includes/class-wc-subscriptions-coupon.php @@ -0,0 +1,701 @@ + __( 'Sign Up Fee Discount', 'woocommerce-subscriptions' ), + 'sign_up_fee_percent' => __( 'Sign Up Fee % Discount', 'woocommerce-subscriptions' ), + 'recurring_fee' => __( 'Recurring Product Discount', 'woocommerce-subscriptions' ), + 'recurring_percent' => __( 'Recurring Product % Discount', 'woocommerce-subscriptions' ), + ) + ); + } + + /** + * Get the discount amount for Subscriptions coupon types + * + * @since 2.0.10 + */ + public static function get_discount_amount( $discount, $discounting_amount, $cart_item, $single, $coupon ) { + + // Only deal with subscriptions coupon types + if ( ! in_array( $coupon->type, array( 'recurring_fee', 'recurring_percent', 'sign_up_fee', 'sign_up_fee_percent', 'renewal_fee', 'renewal_percent', 'renewal_cart' ) ) ) { + return $discount; + } + + $product_id = ( $cart_item['data']->is_type( array( 'subscription_variation' ) ) ) ? $cart_item['data']->variation_id : $cart_item['data']->id; + + // If not a subscription product return the default discount + if ( ! wcs_cart_contains_renewal() && ! WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) ) { + return $discount; + } + // But if cart contains a renewal, we need to handle both subscription products and manually added non-susbscription products that could be part of a subscription + if ( wcs_cart_contains_renewal() && ! self::is_subsbcription_renewal_line_item( $product_id, $cart_item ) ) { + return $discount; + } + + // Set our starting discount amount to 0 + $discount_amount = 0; + + // Item quantity + $cart_item_qty = is_null( $cart_item ) ? 1 : $cart_item['quantity']; + + // Get calculation type + $calculation_type = WC_Subscriptions_Cart::get_calculation_type(); + + // Set the defaults for our logic checks to false + $apply_recurring_coupon = $apply_recurring_percent_coupon = $apply_initial_coupon = $apply_initial_percent_coupon = $apply_renewal_cart_coupon = false; + + // Check if we're applying any recurring discounts to recurring total calculations + if ( 'recurring_total' == $calculation_type ) { + $apply_recurring_coupon = ( 'recurring_fee' == $coupon->type ) ? true : false; + $apply_recurring_percent_coupon = ( 'recurring_percent' == $coupon->type ) ? true : false; + } + + // Check if we're applying any initial discounts + if ( 'none' == $calculation_type ) { + + // If all items have a free trial we don't need to apply recurring coupons to the initial total + if ( ! WC_Subscriptions_Cart::all_cart_items_have_free_trial() ) { + + if ( 'recurring_fee' == $coupon->type ) { + $apply_initial_coupon = true; + } + + if ( 'recurring_percent' == $coupon->type ) { + $apply_initial_percent_coupon = true; + } + } + + // Apply sign-up discounts + if ( ! empty( $cart_item['data']->subscription_sign_up_fee ) ) { + + if ( 'sign_up_fee' == $coupon->type ) { + $apply_initial_coupon = true; + } + + if ( 'sign_up_fee_percent' == $coupon->type ) { + $apply_initial_percent_coupon = true; + } + + // Only Sign up fee coupons apply to sign up fees, adjust the discounting_amount accordingly + if ( in_array( $coupon->type, array( 'sign_up_fee', 'sign_up_fee_percent' ) ) ) { + $discounting_amount = $cart_item['data']->subscription_sign_up_fee; + } else { + $discounting_amount -= $cart_item['data']->subscription_sign_up_fee; + } + } + + // Apply renewal discounts + if ( 'renewal_fee' == $coupon->type ) { + $apply_recurring_coupon = true; + } + if ( 'renewal_percent' == $coupon->type ) { + $apply_recurring_percent_coupon = true; + } + if ( 'renewal_cart' == $coupon->type ) { + $apply_renewal_cart_coupon = true; + } + } + + // Calculate our discount + if ( $apply_recurring_coupon || $apply_initial_coupon ) { + + // Recurring coupons only apply when there is no free trial (carts can have a mix of free trial and non free trial items) + if ( $apply_initial_coupon && 'recurring_fee' == $coupon->type && ! empty( $cart_item['data']->subscription_trial_length ) ) { + $discounting_amount = 0; + } + + $discount_amount = min( $coupon->coupon_amount, $discounting_amount ); + $discount_amount = $single ? $discount_amount : $discount_amount * $cart_item_qty; + + } elseif ( $apply_recurring_percent_coupon ) { + + $discount_amount = ( $discounting_amount / 100 ) * $coupon->amount; + + } elseif ( $apply_initial_percent_coupon ) { + + // Recurring coupons only apply when there is no free trial (carts can have a mix of free trial and non free trial items) + if ( 'recurring_percent' == $coupon->type && ! empty( $cart_item['data']->subscription_trial_length ) ) { + $discounting_amount = 0; + } + + $discount_amount = ( $discounting_amount / 100 ) * $coupon->amount; + + } elseif ( $apply_renewal_cart_coupon ) { + + /** + * See WC Core fixed_cart coupons - we need to divide the discount between rows based on their price in proportion to the subtotal. + * This is so rows with different tax rates get a fair discount, and so rows with no price (free) don't get discounted. + * + * BUT... we also need the subtotal to exclude non renewal products, so user the renewal subtotal + */ + $discount_percent = ( $discounting_amount * $cart_item['quantity'] ) / self::get_renewal_subtotal( $coupon->code ); + + $discount_amount = ( $coupon->amount * $discount_percent ) / $cart_item_qty; + } + + // Round - consistent with WC approach + $discount_amount = round( $discount_amount, WC_ROUNDING_PRECISION ); + + return $discount_amount; + } + + /** + * Determine if the cart contains a discount code of a given coupon type. + * + * Used internally for checking if a WooCommerce discount coupon ('core') has been applied, or for if a specific + * subscription coupon type, like 'recurring_fee' or 'sign_up_fee', has been applied. + * + * @param string $coupon_type Any available coupon type or a special keyword referring to a class of coupons. Can be: + * - 'any' to check for any type of discount + * - 'core' for any core WooCommerce coupon + * - 'recurring_fee' for the recurring amount subscription coupon + * - 'sign_up_fee' for the sign-up fee subscription coupon + * + * @since 1.3.5 + */ + public static function cart_contains_discount( $coupon_type = 'any' ) { + + $contains_discount = false; + $core_coupons = array( 'fixed_product', 'percent_product', 'fixed_cart', 'percent' ); + + if ( WC()->cart->applied_coupons ) { + + foreach ( WC()->cart->applied_coupons as $code ) { + + $coupon = new WC_Coupon( $code ); + + if ( 'any' == $coupon_type || $coupon_type == $coupon->type || ( 'core' == $coupon_type && in_array( $coupon->type, $core_coupons ) ) ) { + $contains_discount = true; + break; + } + } + } + + return $contains_discount; + } + + /** + * Check if a subscription coupon is valid before applying + * + * @since 1.2 + */ + public static function validate_subscription_coupon( $valid, $coupon ) { + + if ( ! apply_filters( 'woocommerce_subscriptions_validate_coupon_type', true, $coupon, $valid ) ) { + return $valid; + } + + self::$coupon_error = ''; + + // ignore non-subscription coupons + if ( ! in_array( $coupon->type, array( 'recurring_fee', 'sign_up_fee', 'recurring_percent', 'sign_up_fee_percent', 'renewal_fee', 'renewal_percent', 'renewal_cart' ) ) ) { + + // but make sure there is actually something for the coupon to be applied to (i.e. not a free trial) + if ( ( wcs_cart_contains_renewal() || WC_Subscriptions_Cart::cart_contains_subscription() ) && 0 == WC()->cart->subtotal ) { + self::$coupon_error = __( 'Sorry, this coupon is only valid for an initial payment and the cart does not require an initial payment.', 'woocommerce-subscriptions' ); + } + } else { + + // prevent subscription coupons from being applied to renewal payments + if ( wcs_cart_contains_renewal() && ! in_array( $coupon->type, array( 'renewal_fee', 'renewal_percent', 'renewal_cart' ) ) ) { + self::$coupon_error = __( 'Sorry, this coupon is only valid for new subscriptions.', 'woocommerce-subscriptions' ); + } + + // prevent subscription coupons from being applied to non-subscription products + if ( ! wcs_cart_contains_renewal() && ! WC_Subscriptions_Cart::cart_contains_subscription() ) { + self::$coupon_error = __( 'Sorry, this coupon is only valid for subscription products.', 'woocommerce-subscriptions' ); + } + + // prevent subscription renewal coupons from being applied to non renewal payments + if ( ! wcs_cart_contains_renewal() && in_array( $coupon->type, array( 'renewal_fee', 'renewal_percent', 'renewal_cart' ) ) ) { + // translators: 1$: coupon code that is being removed + self::$coupon_error = sprintf( __( 'Sorry, the "%1$s" coupon is only valid for renewals.', 'woocommerce-subscriptions' ), $coupon->code ); + } + + // prevent sign up fee coupons from being applied to subscriptions without a sign up fee + if ( 0 == WC_Subscriptions_Cart::get_cart_subscription_sign_up_fee() && in_array( $coupon->type, array( 'sign_up_fee', 'sign_up_fee_percent' ) ) ) { + self::$coupon_error = __( 'Sorry, this coupon is only valid for subscription products with a sign-up fee.', 'woocommerce-subscriptions' ); + } + } + + if ( ! empty( self::$coupon_error ) ) { + $valid = false; + add_filter( 'woocommerce_coupon_error', __CLASS__ . '::add_coupon_error', 10 ); + } + + return $valid; + } + + /** + * Returns a subscription coupon-specific error if validation failed + * + * @since 1.2 + */ + public static function add_coupon_error( $error ) { + + if ( self::$coupon_error ) { + return self::$coupon_error; + } else { + return $error; + } + + } + + /** + * Checks a given product / coupon combination to determine if the subscription should be discounted + * + * @since 1.2 + */ + private static function is_subscription_discountable( $cart_item, $coupon ) { + + $product_cats = wp_get_post_terms( $cart_item['product_id'], 'product_cat', array( 'fields' => 'ids' ) ); + + $this_item_is_discounted = false; + + // Specific products get the discount + if ( sizeof( $coupon->product_ids ) > 0 ) { + + if ( in_array( wcs_get_canonical_product_id( $cart_item ), $coupon->product_ids ) || in_array( $cart_item['data']->get_parent(), $coupon->product_ids ) ) { + $this_item_is_discounted = true; + } + + // Category discounts + } elseif ( sizeof( $coupon->product_categories ) > 0 ) { + + if ( sizeof( array_intersect( $product_cats, $coupon->product_categories ) ) > 0 ) { + $this_item_is_discounted = true; + } + } else { + + // No product ids - all items discounted + $this_item_is_discounted = true; + + } + + // Specific product ID's excluded from the discount + if ( sizeof( $coupon->exclude_product_ids ) > 0 ) { + if ( in_array( wcs_get_canonical_product_id( $cart_item ), $coupon->exclude_product_ids ) || in_array( $cart_item['data']->get_parent(), $coupon->exclude_product_ids ) ) { + $this_item_is_discounted = false; + } + } + + // Specific categories excluded from the discount + if ( sizeof( $coupon->exclude_product_categories ) > 0 ) { + if ( sizeof( array_intersect( $product_cats, $coupon->exclude_product_categories ) ) > 0 ) { + $this_item_is_discounted = false; + } + } + + // Apply filter + return apply_filters( 'woocommerce_item_is_discounted', $this_item_is_discounted, $cart_item, $before_tax = false ); + } + + /** + * Sets which coupons should be applied for this calculation. + * + * This function is hooked to "woocommerce_before_calculate_totals" so that WC will calculate a subscription + * product's total based on the total of it's price per period and sign up fee (if any). + * + * @since 1.3.5 + */ + public static function remove_coupons( $cart ) { + + $calculation_type = WC_Subscriptions_Cart::get_calculation_type(); + + // Only hook when totals are being calculated completely (on cart & checkout pages) + if ( 'none' == $calculation_type || ! WC_Subscriptions_Cart::cart_contains_subscription() || ( ! is_checkout() && ! is_cart() && ! defined( 'WOOCOMMERCE_CHECKOUT' ) && ! defined( 'WOOCOMMERCE_CART' ) ) ) { + return; + } + + $applied_coupons = $cart->get_applied_coupons(); + + // If we're calculating a sign-up fee or recurring fee only amount, remove irrelevant coupons + if ( ! empty( $applied_coupons ) ) { + + // Keep track of which coupons, if any, need to be reapplied immediately + $coupons_to_reapply = array(); + + foreach ( $applied_coupons as $coupon_code ) { + + $coupon = new WC_Coupon( $coupon_code ); + + if ( in_array( $coupon->type, array( 'recurring_fee', 'recurring_percent' ) ) ) { // always apply coupons to their specific calculation case + if ( 'recurring_total' == $calculation_type ) { + $coupons_to_reapply[] = $coupon_code; + } elseif ( 'none' == $calculation_type && ! WC_Subscriptions_Cart::all_cart_items_have_free_trial() ) { // sometimes apply recurring coupons to initial total + $coupons_to_reapply[] = $coupon_code; + } else { + self::$removed_coupons[] = $coupon_code; + } + } elseif ( ( 'none' == $calculation_type ) && ! in_array( $coupon->type, array( 'recurring_fee', 'recurring_percent' ) ) ) { // apply all coupons to the first payment + $coupons_to_reapply[] = $coupon_code; + } else { + self::$removed_coupons[] = $coupon_code; + } + } + + // Now remove all coupons (WC only provides a function to remove all coupons) + $cart->remove_coupons(); + + // And re-apply those which relate to this calculation + $cart->applied_coupons = $coupons_to_reapply; + + if ( isset( $cart->coupons ) ) { // WC 2.3+ + $cart->coupons = $cart->get_coupons(); + } + } + } + + /** + * Add our recurring product coupon types to the list of coupon types that apply to individual products. + * Used to control which validation rules will apply. + * + * @param array $product_coupon_types + * @return array $product_coupon_types + */ + public static function filter_product_coupon_types( $product_coupon_types ) { + + if ( is_array( $product_coupon_types ) ) { + $product_coupon_types = array_merge( $product_coupon_types, array( 'recurring_fee', 'recurring_percent', 'sign_up_fee', 'sign_up_fee_percent', 'renewal_fee', 'renewal_percent', 'renewal_cart' ) ); + } + + return $product_coupon_types; + } + + /** + * Get subtotals for a renewal subscription so that our pseudo renewal_cart discounts can be applied correctly even if other items have been added to the cart + * + * @param string $code coupon code + * @return array subtotal + * @since 2.0.10 + */ + private static function get_renewal_subtotal( $code ) { + + $renewal_coupons = WC()->session->get( 'wcs_renewal_coupons' ); + + if ( empty( $renewal_coupons ) ) { + return false; + } + + $subtotal = 0; + + foreach ( $renewal_coupons as $subscription_id => $coupons ) { + + foreach ( $coupons as $coupon ) { + + if ( $coupon->code == $code ) { + + if ( $subscription = wcs_get_subscription( $subscription_id ) ) { + $subtotal = $subscription->get_subtotal(); + } + break; + } + } + } + + return $subtotal; + } + + /** + * Check if a product is a renewal order line item (rather than a "susbscription") - to pick up non-subsbcription products added a subscription manually + * + * @param int $product_id + * @param array $cart_item + * @param WC_Cart $cart The WooCommerce cart object. + * @return boolean whether a product is a renewal order line item + * @since 2.0.10 + */ + private static function is_subsbcription_renewal_line_item( $product_id, $cart_item ) { + + $is_subscription_line_item = false; + + if ( is_object( $product_id ) ) { + $product = $product_id; + $product_id = $product->id; + } elseif ( is_numeric( $product_id ) ) { + $product = wc_get_product( $product_id ); + } + + if ( ! empty( $cart_item['subscription_renewal'] ) ) { + if ( $subscription = wcs_get_subscription( $cart_item['subscription_renewal']['subscription_id'] ) ) { + foreach ( $subscription->get_items() as $item ) { + $item_product_id = ( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id']; + if ( ! empty( $item_product_id ) && $item_product_id == $product_id ) { + $is_subscription_line_item = true; + } + } + } + } + + return apply_filters( 'woocommerce_is_subscription_renewal_line_item', $is_subscription_line_item, $product_id, $cart_item ); + } + + /* Deprecated */ + + /** + * Apply sign up fee or recurring fee discount + * + * @since 1.2 + */ + public static function apply_subscription_discount( $original_price, $cart_item, $cart ) { + _deprecated_function( __METHOD__, '2.0.10', 'Have moved to filtering on "woocommerce_coupon_get_discount_amount" to return discount amount. See: '. __CLASS__ .'::get_discount_amount()' ); + + $product_id = ( $cart_item['data']->is_type( array( 'subscription_variation' ) ) ) ? $cart_item['data']->variation_id : $cart_item['data']->id; + + if ( ! WC_Subscriptions_Product::is_subscription( $product_id ) ) { + return $original_price; + } + + $price = $calculation_price = $original_price; + + $calculation_type = WC_Subscriptions_Cart::get_calculation_type(); + + if ( ! empty( $cart->applied_coupons ) ) { + + foreach ( $cart->applied_coupons as $code ) { + + $coupon = new WC_Coupon( $code ); + + // Pre 2.5 is_valid_for_product() does not use wc_get_product_coupon_types() + if ( WC_Subscriptions::is_woocommerce_pre( '2.5' ) ) { + $is_valid_for_product = true; + } else { + $is_valid_for_product = $coupon->is_valid_for_product( wc_get_product( $product_id ), $cart_item ); + } + + if ( $coupon->apply_before_tax() && $coupon->is_valid() && $is_valid_for_product ) { + + $apply_recurring_coupon = $apply_recurring_percent_coupon = $apply_initial_coupon = $apply_initial_percent_coupon = false; + + // Apply recurring fee discounts to recurring total calculations + if ( 'recurring_total' == $calculation_type ) { + $apply_recurring_coupon = ( 'recurring_fee' == $coupon->type ) ? true : false; + $apply_recurring_percent_coupon = ( 'recurring_percent' == $coupon->type ) ? true : false; + } + + if ( 'none' == $calculation_type ) { + + // If all items have a free trial we don't need to apply recurring coupons to the initial total + if ( ! WC_Subscriptions_Cart::all_cart_items_have_free_trial() ) { + + if ( 'recurring_fee' == $coupon->type ) { + $apply_initial_coupon = true; + } + + if ( 'recurring_percent' == $coupon->type ) { + $apply_initial_percent_coupon = true; + } + } + + // Apply sign-up discounts to initial total + if ( ! empty( $cart_item['data']->subscription_sign_up_fee ) ) { + + if ( 'sign_up_fee' == $coupon->type ) { + $apply_initial_coupon = true; + } + + if ( 'sign_up_fee_percent' == $coupon->type ) { + $apply_initial_percent_coupon = true; + } + + $calculation_price = $cart_item['data']->subscription_sign_up_fee; + } + } + + if ( $apply_recurring_coupon || $apply_initial_coupon ) { + + $discount_amount = ( $calculation_price < $coupon->amount ) ? $calculation_price : $coupon->amount; + + // Recurring coupons only apply when there is no free trial (carts can have a mix of free trial and non free trial items) + if ( $apply_initial_coupon && 'recurring_fee' == $coupon->type && ! empty( $cart_item['data']->subscription_trial_length ) ) { + $discount_amount = 0; + } + + $cart->discount_cart = $cart->discount_cart + ( $discount_amount * $cart_item['quantity'] ); + $cart = self::increase_coupon_discount_amount( $cart, $coupon->code, $discount_amount * $cart_item['quantity'] ); + + $price = $price - $discount_amount; + + } elseif ( $apply_recurring_percent_coupon ) { + + $discount_amount = round( ( $calculation_price / 100 ) * $coupon->amount, WC()->cart->dp ); + + $cart->discount_cart = $cart->discount_cart + ( $discount_amount * $cart_item['quantity'] ); + $cart = self::increase_coupon_discount_amount( $cart, $coupon->code, $discount_amount * $cart_item['quantity'] ); + + $price = $price - $discount_amount; + + } elseif ( $apply_initial_percent_coupon ) { + + // Recurring coupons only apply when there is no free trial (carts can have a mix of free trial and non free trial items) + if ( 'recurring_percent' == $coupon->type && empty( $cart_item['data']->subscription_trial_length ) ) { + $amount_to_discount = $cart_item['data']->subscription_price; + } else { + $amount_to_discount = 0; + } + + // Sign up fee coupons only apply to sign up fees + if ( 'sign_up_fee_percent' == $coupon->type ) { + $amount_to_discount = $cart_item['data']->subscription_sign_up_fee; + } + + $discount_amount = round( ( $amount_to_discount / 100 ) * $coupon->amount, WC()->cart->dp ); + + $cart->discount_cart = $cart->discount_cart + $discount_amount * $cart_item['quantity']; + $cart = self::increase_coupon_discount_amount( $cart, $coupon->code, $discount_amount * $cart_item['quantity'] ); + + $price = $price - $discount_amount; + } + } + } + + if ( $price < 0 ) { + $price = 0; + } + } + + return $price; + } + + /** + * Store how much discount each coupon grants. + * + * @since 2.0 + * @param WC_Cart $cart The WooCommerce cart object. + * @param mixed $code + * @param mixed $amount + * @return WC_Cart $cart + */ + public static function increase_coupon_discount_amount( $cart, $code, $amount ) { + _deprecated_function( __METHOD__, '2.0.10' ); + + if ( empty( $cart->coupon_discount_amounts[ $code ] ) ) { + $cart->coupon_discount_amounts[ $code ] = 0; + } + + $cart->coupon_discount_amounts[ $code ] += $amount; + + return $cart; + } + + /** + * Determines if cart contains a recurring fee discount code + * + * Does not check if the code is valid, etc + * + * @since 1.2 + */ + public static function cart_contains_recurring_discount() { + _deprecated_function( __METHOD__, '1.3.5', __CLASS__ .'::cart_contains_discount( "recurring_fee" )' ); + return self::cart_contains_discount( 'recurring_fee' ); + } + + /** + * Determines if cart contains a sign up fee discount code + * + * Does not check if the code is valid, etc + * + * @since 1.2 + */ + public static function cart_contains_sign_up_discount() { + _deprecated_function( __METHOD__, '1.3.5', __CLASS__ .'::cart_contains_discount( "sign_up_fee" )' ); + return self::cart_contains_discount( 'sign_up_fee' ); + } + + /** + * Restores discount coupons which had been removed for special subscription calculations. + * + * @since 1.3.5 + */ + public static function restore_coupons( $cart ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( ! empty( self::$removed_coupons ) ) { + + // Can't use $cart->add_dicount here as it calls calculate_totals() + $cart->applied_coupons = array_merge( $cart->applied_coupons, self::$removed_coupons ); + + if ( isset( $cart->coupons ) ) { // WC 2.3+ + $cart->coupons = $cart->get_coupons(); + } + + self::$removed_coupons = array(); + } + } + + /** + * Apply sign up fee or recurring fee discount before tax is calculated + * + * @since 1.2 + */ + public static function apply_subscription_discount_before_tax( $original_price, $cart_item, $cart ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ .'::apply_subscription_discount( $original_price, $cart_item, $cart )' ); + return self::apply_subscription_discount( $original_price, $cart_item, $cart ); + } + + /** + * Apply sign up fee or recurring fee discount after tax is calculated + * + * @since 1.2 + * @version 1.3.6 + */ + public static function apply_subscription_discount_after_tax( $coupon, $cart_item, $price ) { + _deprecated_function( __METHOD__, '2.0', 'WooCommerce 2.3 removed after tax discounts. Use ' . __CLASS__ .'::apply_subscription_discount( $original_price, $cart_item, $cart )' ); + } +} + +WC_Subscriptions_Coupon::init(); diff --git a/includes/class-wc-subscriptions-email.php b/includes/class-wc-subscriptions-email.php new file mode 100644 index 0000000..1599160 --- /dev/null +++ b/includes/class-wc-subscriptions-email.php @@ -0,0 +1,244 @@ +mailer(); + + if ( $subscription->has_status( array( 'pending-cancel', 'cancelled' ) ) && 'true' !== get_post_meta( $subscription->id, '_cancelled_email_sent', true ) ) { + do_action( 'cancelled_subscription_notification', $subscription ); + } + } + + /** + * Init the mailer and call the notifications for the renewal orders. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return void + */ + public static function send_renewal_order_email( $order_id ) { + WC()->mailer(); + + if ( wcs_order_contains_renewal( $order_id ) ) { + do_action( current_filter() . '_renewal_notification', $order_id ); + } + } + + /** + * If the order is a renewal order, don't send core emails. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return void + */ + public static function maybe_remove_woocommerce_email( $order_id ) { + if ( wcs_order_contains_renewal( $order_id ) || wcs_order_contains_switch( $order_id ) ) { + remove_action( current_filter(), array( 'WC_Emails', 'send_transactional_email' ) ); + } + } + + /** + * If the order is a renewal order, don't send core emails. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return void + */ + public static function maybe_reattach_woocommerce_email( $order_id ) { + if ( wcs_order_contains_renewal( $order_id ) || wcs_order_contains_switch( $order_id ) ) { + add_action( current_filter(), array( 'WC_Emails', 'send_transactional_email' ) ); + } + } + + /** + * If viewing a renewal order on the the Edit Order screen, set the available email actions for the order to use + * renewal order emails, not core WooCommerce order emails. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return void + */ + public static function renewal_order_emails_available( $available_emails ) { + global $theorder; + + if ( wcs_order_contains_renewal( $theorder->id ) ) { + $available_emails = array( + 'new_renewal_order', + 'customer_processing_renewal_order', + 'customer_completed_renewal_order', + 'customer_renewal_invoice', + ); + } + + return $available_emails; + } + + /** + * Init the mailer and call the notifications for subscription switch orders. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return void + */ + public static function send_switch_order_email( $order_id ) { + WC()->mailer(); + + if ( wcs_order_contains_switch( $order_id ) ) { + do_action( current_filter() . '_switch_notification', $order_id ); + } + } + + /** + * Generate an order items table using a WC compatible version of the function. + * + * @param object $order + * @param array $args { + * @type bool 'show_download_links' + * @type bool 'show_sku' + * @type bool 'show_purchase_note' + * @type array 'image_size' + * @type bool 'plain_text' + * } + * @return string email order items table html + */ + public static function email_order_items_table( $order, $args = array() ) { + $items_table = ''; + + if ( is_numeric( $order ) ) { + $order = wc_get_order( $order ); + } + + if ( is_a( $order, 'WC_Abstract_Order' ) ) { + + if ( WC_Subscriptions::is_woocommerce_pre( '2.5' ) ) { + + $items_table = call_user_func_array( array( $order, 'email_order_items_table' ), $args ); + } else { + + // 2.5 doesn't support both the show_download_links or show_purchase_note parameters but uses $order->is_download_permitted and $order->is_paid instead + $show_download_links_callback = ( isset( $args['show_download_links'] ) && $args['show_download_links'] ) ? '__return_true' : '__return_false'; + $show_purchase_note_callback = ( isset( $args['show_purchase_note'] ) && $args['show_purchase_note'] ) ? '__return_true' : '__return_false'; + + unset( $args['show_download_links'] ); + unset( $args['show_purchase_note'] ); + + add_filter( 'woocommerce_order_is_download_permitted', $show_download_links_callback ); + add_filter( 'woocommerce_order_is_paid', $show_purchase_note_callback ); + + $items_table = $order->email_order_items_table( $args ); + + remove_filter( 'woocommerce_order_is_download_permitted', $show_download_links_callback ); + remove_filter( 'woocommerce_order_is_paid', $show_purchase_note_callback ); + } + } + + return $items_table; + } + + /** + * Init the mailer and call the notifications for the current filter. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return void + * @deprecated 2.0 + */ + public static function send_subscription_email( $user_id, $subscription_key ) { + _deprecated_function( __FUNCTION__, '2.0' ); + } +} + +WC_Subscriptions_Email::init(); diff --git a/includes/class-wc-subscriptions-manager.php b/includes/class-wc-subscriptions-manager.php new file mode 100644 index 0000000..8975abf --- /dev/null +++ b/includes/class-wc-subscriptions-manager.php @@ -0,0 +1,2413 @@ +has_status( 'active' ) ) { + return false; + } + + // If the subscription is using manual payments, the gateway isn't active or it manages scheduled payments + if ( 0 == $subscription->get_total() || $subscription->is_manual() || empty( $subscription->payment_method ) || ! $subscription->payment_method_supports( 'gateway_scheduled_payments' ) ) { + + // Always put the subscription on hold in case something goes wrong while trying to process renewal + $subscription->update_status( 'on-hold', _x( 'Subscription renewal payment due:', 'used in order note as reason for why subscription status changed', 'woocommerce-subscriptions' ) ); + + // Generate a renewal order for payment gateways to use to record the payment (and determine how much is due) + $renewal_order = wcs_create_renewal_order( $subscription ); + + if ( is_wp_error( $renewal_order ) ) { + // let's try this again + $renewal_order = wcs_create_renewal_order( $subscription ); + + if ( is_wp_error( $renewal_order ) ) { + throw new Exception( __( 'Error: Unable to create renewal order from scheduled payment. Please try again.', 'woocommerce-subscriptions' ) ); + } + } + + if ( 0 == $subscription->get_total() ) { + + $renewal_order->payment_complete(); + + $subscription->update_status( 'active' ); // don't call payment_complete() because technically, no payment was received + + } else { + + if ( $subscription->is_manual() ) { + do_action( 'woocommerce_generated_manual_renewal_order', $renewal_order->id ); + } else { + $renewal_order->set_payment_method( $subscription->payment_gateway ); + } + } + } + } + + /** + * Expires a single subscription on a users account. + * + * @param int $subscription_id The ID of a 'shop_subscription' post + * @since 1.0 + */ + public static function expire_subscription( $subscription_id, $deprecated = null ) { + + if ( null !== $deprecated ) { + _deprecated_argument( __METHOD__, '2.0', 'The subscription key is deprecated. Use a subscription post ID' ); + $subscription = wcs_get_subscription_from_key( $deprecated ); + } else { + $subscription = wcs_get_subscription( $subscription_id ); + } + + if ( false === $subscription ) { + throw new InvalidArgumentException( sprintf( __( 'Subscription doesn\'t exist in scheduled action: %d', 'woocommerce-subscriptions' ), $subscription_id ) ); + } + + $subscription->update_status( 'expired' ); + } + + /** + * Fires when a cancelled subscription reaches the end of its prepaid term. + * + * @param int $subscription_id The ID of a 'shop_subscription' post + * @since 1.3 + */ + public static function subscription_end_of_prepaid_term( $subscription_id, $deprecated = null ) { + + if ( null !== $deprecated ) { + _deprecated_argument( __METHOD__, '2.0', 'The subscription key is deprecated. Use a subscription post ID' ); + $subscription = wcs_get_subscription_from_key( $deprecated ); + } else { + $subscription = wcs_get_subscription( $subscription_id ); + } + + $subscription->update_status( 'cancelled' ); + } + + /** + * Records a payment on a subscription. + * + * @param int $user_id The id of the user who owns the subscription. + * @param string $subscription_key A subscription key of the form obtained by @see get_subscription_key( $order_id, $product_id ) + * @since 1.0 + */ + public static function process_subscription_payment( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::payment_complete()' ); + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + $subscription->payment_complete(); + + // Reset failed payment count & suspension count + $subscription = array(); // we only want to reset the failed payments and susp count + $subscription['failed_payments'] = $subscription['suspension_count'] = 0; + self::update_users_subscriptions( $user_id, array( $subscription_key => $subscription ) ); + } + + /** + * Processes a failed payment on a subscription by recording the failed payment and cancelling the subscription if it exceeds the + * maximum number of failed payments allowed on the site. + * + * @param int $user_id The id of the user who owns the expiring subscription. + * @param string $subscription_key A subscription key of the form obtained by @see get_subscription_key( $order_id, $product_id ) + * @since 1.0 + */ + public static function process_subscription_payment_failure( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::payment_failed()' ); + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + // Allow a short circuit for plugins & payment gateways to force max failed payments exceeded + if ( apply_filters( 'woocommerce_subscriptions_max_failed_payments_exceeded', false, $user_id, $subscription_key ) ) { + $new_status = 'cancelled'; + } else { + $new_status = 'on-hold'; + } + + $subscription->payment_failed( $new_status ); + + // Reset failed payment count & suspension count + $subscription = array(); // we only want to reset the failed payments and susp count + $subscription['failed_payments'] = $subscription['failed_payments'] + 1; + self::update_users_subscriptions( $user_id, array( $subscription_key => $subscription ) ); + } + + /** + * This function should be called whenever a subscription payment is made on an order. This includes + * when the subscriber signs up and for a recurring payment. + * + * The function is a convenience wrapper for @see self::process_subscription_payment(), so if calling that + * function directly, do not call this function also. + * + * @param WC_Order|int $order The order or ID of the order for which subscription payments should be marked against. + * @since 1.0 + */ + public static function process_subscription_payments_on_order( $order, $product_id = '' ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + $subscription->payment_complete(); + } + + do_action( 'processed_subscription_payments_for_order', $order ); + } + } + + /** + * This function should be called whenever a subscription payment has failed. + * + * The function is a convenience wrapper for @see self::process_subscription_payment_failure(), so if calling that + * function directly, do not call this function also. + * + * @param int|WC_Order $order The order or ID of the order for which subscription payments should be marked against. + * @since 1.0 + */ + public static function process_subscription_payment_failure_on_order( $order, $product_id = '' ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + $subscription->payment_failed(); + } + + do_action( 'processed_subscription_payment_failure_for_order', $order ); + } + } + + /** + * Activates all the subscriptions created by a given order. + * + * @param WC_Order|int $order The order or ID of the order for which subscriptions should be marked as activated. + * @since 1.0 + */ + public static function activate_subscriptions_for_order( $order ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + + try { + $subscription->update_status( 'active' ); + } catch ( Exception $e ) { + // translators: $1: order number, $2: error message + $subscription->add_order_note( sprintf( __( 'Failed to activate subscription status for order #%1$s: %2$s', 'woocommerce-subscriptions' ), is_object( $order ) ? $order->get_order_number() : $order, $e->getMessage() ) ); + } + } + + do_action( 'subscriptions_activated_for_order', $order ); + } + } + + /** + * Suspends all the subscriptions on an order by changing their status to "on-hold". + * + * @param WC_Order|int $order The order or ID of the order for which subscriptions should be marked as activated. + * @since 1.0 + */ + public static function put_subscription_on_hold_for_order( $order ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + + try { + if ( ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) ) { + $subscription->update_status( 'on-hold' ); + } + } catch ( Exception $e ) { + // translators: $1: order number, $2: error message + $subscription->add_order_note( sprintf( __( 'Failed to update subscription status after order #%1$s was put on-hold: %2$s', 'woocommerce-subscriptions' ), is_object( $order ) ? $order->get_order_number() : $order, $e->getMessage() ) ); + } + } + + do_action( 'subscriptions_put_on_hold_for_order', $order ); + } + } + + /** + * Mark all subscriptions in an order as cancelled on the user's account. + * + * @param WC_Order|int $order The order or ID of the order for which subscriptions should be marked as cancelled. + * @since 1.0 + */ + public static function cancel_subscriptions_for_order( $order ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + + try { + if ( ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) ) { + $subscription->cancel_order(); + } + } catch ( Exception $e ) { + // translators: $1: order number, $2: error message + $subscription->add_order_note( sprintf( __( 'Failed to cancel subscription after order #%1$s was cancelled: %2$s', 'woocommerce-subscriptions' ), is_object( $order ) ? $order->get_order_number() : $order, $e->getMessage() ) ); + } + } + + do_action( 'subscriptions_cancelled_for_order', $order ); + } + } + + /** + * Marks all the subscriptions in an order as expired + * + * @param WC_Order|int $order The order or ID of the order for which subscriptions should be marked as expired. + * @since 1.0 + */ + public static function expire_subscriptions_for_order( $order ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + + try { + if ( ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) ) { + $subscription->update_status( 'expired' ); + } + } catch ( Exception $e ) { + // translators: $1: order number, $2: error message + $subscription->add_order_note( sprintf( __( 'Failed to set subscription as expired for order #%1$s: %2$s', 'woocommerce-subscriptions' ), is_object( $order ) ? $order->get_order_number() : $order, $e->getMessage() ) ); + } + } + + do_action( 'subscriptions_expired_for_order', $order ); + } + } + + /** + * Called when a sign up fails during the payment processing step. + * + * @param WC_Order|int $order The order or ID of the order for which subscriptions should be marked as failed. + * @since 1.0 + */ + public static function failed_subscription_sign_ups_for_order( $order ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ); + + if ( ! empty( $subscriptions ) ) { + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + // Set subscription status to failed and log failure + if ( $order->has_status( 'failed' ) ) { + $order->update_status( 'failed', __( 'Subscription sign up failed.', 'woocommerce-subscriptions' ) ); + } + + foreach ( $subscriptions as $subscription ) { + + try { + $subscription->payment_failed(); + + } catch ( Exception $e ) { + // translators: $1: order number, $2: error message + $subscription->add_order_note( sprintf( __( 'Failed to process failed payment on subscription for order #%1$s: %2$s', 'woocommerce-subscriptions' ), is_object( $order ) ? $order->get_order_number() : $order, $e->getMessage() ) ); + } + } + + do_action( 'failed_subscription_sign_ups_for_order', $order ); + } + } + + /** + * Uses the details of an order to create a pending subscription on the customers account + * for a subscription product, as specified with $product_id. + * + * @param int|WC_Order $order The order ID or WC_Order object to create the subscription from. + * @param int $product_id The ID of the subscription product on the order, if a variation, it must be the variation's ID. + * @param array $args An array of name => value pairs to customise the details of the subscription, including: + * 'start_date' A MySQL formatted date/time string on which the subscription should start, in UTC timezone + * 'expiry_date' A MySQL formatted date/time string on which the subscription should expire, in UTC timezone + * @since 1.1 + */ + public static function create_pending_subscription_for_order( $order, $product_id, $args = array() ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_create_subscription()' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + if ( ! WC_Subscriptions_Product::is_subscription( $product_id ) ) { + return; + } + + $args = wp_parse_args( $args, array( + 'start_date' => get_gmt_from_date( $order->order_date ), + 'expiry_date' => '', + ) ); + + $billing_period = WC_Subscriptions_Product::get_period( $product_id ); + $billing_interval = WC_Subscriptions_Product::get_interval( $product_id ); + + // Support passing timestamps + $args['start_date'] = is_numeric( $args['start_date'] ) ? date( 'Y-m-d H:i:s', $args['start_date'] ) : $args['start_date']; + + $product = wc_get_product( $product_id ); + + // Check if there is already a subscription for this product and order + $subscriptions = wcs_get_subscriptions( array( 'order_id' => $order->id, 'product_id' => $product_id ) ); + + if ( ! empty( $subscriptions ) ) { + + $subscription = array_pop( $subscriptions ); + + // Make sure the subscription is pending and start date is set correctly + wp_update_post( array( + 'ID' => $subscription->id, + 'post_status' => 'wc-' . apply_filters( 'woocommerce_default_subscription_status', 'pending' ), + 'post_date' => get_date_from_gmt( $args['start_date'] ), + ) ); + + } else { + + $subscription = wcs_create_subscription( array( + 'start_date' => get_date_from_gmt( $args['start_date'] ), + 'order_id' => $order->id, + 'customer_id' => $order->get_user_id(), + 'billing_period' => $billing_period, + 'billing_interval' => $billing_interval, + 'customer_note' => $order->customer_note, + ) ); + + if ( is_wp_error( $subscription ) ) { + throw new Exception( __( 'Error: Unable to create subscription. Please try again.', 'woocommerce-subscriptions' ) ); + } + + $item_id = $subscription->add_product( + $product, + 1, + array( + 'variation' => ( method_exists( $product, 'get_variation_attributes' ) ) ? $product->get_variation_attributes() : array(), + 'totals' => array( + 'subtotal' => $product->get_price(), + 'subtotal_tax' => 0, + 'total' => $product->get_price(), + 'tax' => 0, + 'tax_data' => array( 'subtotal' => array(), 'total' => array() ), + ), + ) + ); + + if ( ! $item_id ) { + throw new Exception( __( 'Error: Unable to add product to created subscription. Please try again.', 'woocommerce-subscriptions' ) ); + } + } + + // Make sure some of the meta is copied form the order rather than the store's defaults + update_post_meta( $subscription->id, '_order_currency', $order->order_currency ); + update_post_meta( $subscription->id, '_prices_include_tax', $order->prices_include_tax ); + + // Adding a new subscription so set the expiry date/time from the order date + if ( ! empty( $args['expiry_date'] ) ) { + if ( is_numeric( $args['expiry_date'] ) ) { + $args['expiry_date'] = date( 'Y-m-d H:i:s', $args['expiry_date'] ); + } + + $expiration = $args['expiry_date']; + } else { + $expiration = WC_Subscriptions_Product::get_expiration_date( $product_id, $args['start_date'] ); + } + + // Adding a new subscription so set the expiry date/time from the order date + $trial_expiration = WC_Subscriptions_Product::get_trial_expiration_date( $product_id, $args['start_date'] ); + + $dates_to_update = array(); + + if ( $trial_expiration > 0 ) { + $dates_to_update['trial_end'] = $trial_expiration; + } + + if ( $expiration > 0 ) { + $dates_to_update['end'] = $expiration; + } + + if ( ! empty( $dates_to_update ) ) { + $subscription->update_dates( $dates_to_update ); + } + + // Set the recurring totals on the subscription + $subscription->set_total( 0, 'tax' ); + $subscription->set_total( $product->get_price(), 'total' ); + + $subscription->add_order_note( __( 'Pending subscription created.', 'woocommerce-subscriptions' ) ); + + do_action( 'pending_subscription_created_for_order', $order, $product_id ); + } + + /** + * Creates subscriptions against a users account with a status of pending when a user creates + * an order containing subscriptions. + * + * @param int|WC_Order $order The order ID or WC_Order object to create the subscription from. + * @since 1.0 + */ + public static function process_subscriptions_on_checkout( $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions_Checkout::process_checkout()' ); + + if ( ! empty( $_POST['_wpnonce'] ) && wp_verify_nonce( $_POST['_wpnonce'], 'woocommerce-process_checkout' ) ) { + WC_Subscriptions_Checkout::process_checkout( $order, $_POST ); + } + } + + /** + * Updates a user's subscriptions for each subscription product in the order. + * + * @param WC_Order $order The order to get subscriptions and user details from. + * @param string $status (optional) A status to change the subscriptions in an order to. Default is 'active'. + * @since 1.0 + */ + public static function update_users_subscriptions_for_order( $order, $status = 'pending' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions::update_status()' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + if ( 'suspend' === $status ) { + $status = 'on-hold'; + _deprecated_argument( __METHOD__, '1.2', 'The "suspend" status value is deprecated. Use "on-hold"' ); + } + + foreach ( wcs_get_subscriptions_for_order( $order->id, array( 'order_type' => 'parent' ) ) as $subscription_id => $subscription ) { + + switch ( $status ) { + case 'cancelled' : + $subscription->cancel_order(); + break; + case 'active' : + case 'expired' : + case 'on-hold' : + $subscription->update_status( $status ); + break; + case 'failed' : + _deprecated_argument( __METHOD__, '2.0', 'The "failed" status value is deprecated.' ); + self::failed_subscription_signup( $order->user_id, $subscription_id ); + break; + case 'pending' : + _deprecated_argument( __METHOD__, '2.0', 'The "pending" status value is deprecated.' ); + default : + self::create_pending_subscription_for_order( $order ); + break; + } + } + + do_action( 'updated_users_subscriptions_for_order', $order, $status ); + } + + /** + * Takes a user ID and array of subscription details and updates the users subscription details accordingly. + * + * @uses wp_parse_args To allow only part of a subscription's details to be updated, like status. + * @param int $user_id The ID of the user for whom subscription details should be updated + * @param array $subscriptions An array of arrays with a subscription key and corresponding 'detail' => 'value' pair. Can alter any of these details: + * 'start_date' The date the subscription was activated + * 'expiry_date' The date the subscription expires or expired, false if the subscription will never expire + * 'failed_payments' The date the subscription's trial expires or expired, false if the subscription has no trial period + * 'end_date' The date the subscription ended, false if the subscription has not yet ended + * 'status' Subscription status can be: cancelled, active, expired or failed + * 'completed_payments' An array of MySQL formatted dates for all payments that have been made on the subscription + * 'failed_payments' An integer representing a count of failed payments + * 'suspension_count' An integer representing a count of the number of times the subscription has been suspended for this billing period + * @since 1.0 + */ + public static function update_users_subscriptions( $user_id, $subscriptions ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions API methods' ); + + foreach ( $subscriptions as $subscription_key => $new_subscription_details ) { + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( isset( $new_subscription_details['status'] ) && 'deleted' == $new_subscription_details['status'] ) { + wp_delete_post( $subscription->id ); + } else { + // There is no direct analog for this in WC_Subscription, so we need to call the deprecated method + self::update_subscription( $subscription_key, $new_subscription_details ); + } + } + + do_action( 'updated_users_subscriptions', $user_id, $subscriptions ); + + return self::get_users_subscriptions( $user_id ); // We need to call this deprecated method to preserve the return value in the deprecated array structure + } + + /** + * Takes a subscription key and array of subscription details and updates the users subscription details accordingly. + * + * @uses wp_parse_args To allow only part of a subscription's details to be updated, like status. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param array $new_subscription_details An array of arrays with a subscription key and corresponding 'detail' => 'value' pair. Can alter any of these details: + * 'start_date' The date the subscription was activated + * 'expiry_date' The date the subscription expires or expired, false if the subscription will never expire + * 'failed_payments' The date the subscription's trial expires or expired, false if the subscription has no trial period + * 'end_date' The date the subscription ended, false if the subscription has not yet ended + * 'status' Subscription status can be: cancelled, active, expired or failed + * 'completed_payments' An array of MySQL formatted dates for all payments that have been made on the subscription + * 'failed_payments' An integer representing a count of failed payments + * 'suspension_count' An integer representing a count of the number of times the subscription has been suspended for this billing period + * @since 1.4 + */ + public static function update_subscription( $subscription_key, $new_subscription_details ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions API methods' ); + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( isset( $new_subscription_details['status'] ) && 'deleted' == $new_subscription_details['status'] ) { + + wp_delete_post( $subscription->id ); + + } else { + + foreach ( $new_subscription_details as $meta_key => $meta_value ) { + switch ( $meta_key ) { + case 'start_date' : + $subscription->update_dates( array( 'start' => $meta_value ) ); + break; + case 'trial_expiry_date' : + $subscription->update_dates( array( 'trial_end' => $meta_value ) ); + break; + case 'expiry_date' : + $subscription->update_dates( array( 'end' => $meta_value ) ); + break; + case 'failed_payments' : + _deprecated_argument( __METHOD__, '2.0', 'The "failed_payments" meta value is deprecated. Create a renewal order with "failed" status instead.' ); + break; + case 'completed_payments' : + _deprecated_argument( __METHOD__, '2.0', 'The "completed_payments" meta value is deprecated. Create a renewal order with completed payment instead.' ); + break; + case 'suspension_count' : + $subscription->update_suspension_count( $subscription->suspension_count + 1 ); + break; + } + } + } + + do_action( 'updated_users_subscription', $subscription_key, $new_subscription_details ); + + return wcs_get_subscription_in_deprecated_structure( $subscription ); + } + + /** + * Takes a user ID and cancels any subscriptions that user has. + * + * @uses wp_parse_args To allow only part of a subscription's details to be updated, like status. + * @param int $user_id The ID of the user for whom subscription details should be updated + * @since 1.3.8 + */ + public static function cancel_users_subscriptions( $user_id ) { + + $subscriptions = wcs_get_users_subscriptions( $user_id ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + if ( $subscription->can_be_updated_to( 'cancelled' ) ) { + $subscription->update_status( 'cancelled' ); + } + } + + do_action( 'cancelled_users_subscriptions', $user_id ); + } + } + + /** + * Takes a user ID and cancels any subscriptions that user has on any site in a WordPress network + * + * @uses wp_parse_args To allow only part of a subscription's details to be updated, like status. + * @param int $user_id The ID of the user for whom subscription details should be updated + * @since 1.3.8 + */ + public static function cancel_users_subscriptions_for_network( $user_id ) { + + $sites = get_blogs_of_user( $user_id ); + + if ( ! empty( $sites ) ) { + + foreach ( $sites as $site ) { + + switch_to_blog( $site->userblog_id ); + + self::cancel_users_subscriptions( $user_id ); + + restore_current_blog(); + } + } + + do_action( 'cancelled_users_subscriptions_for_network', $user_id ); + } + + /** + * Clear all subscriptions for a given order. + * + * @param WC_Order $order The order for which subscriptions should be cleared. + * @since 1.0 + */ + public static function clear_users_subscriptions_from_order( $order ) { + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription_id => $subscription ) { + wp_delete_post( $subscription->id ); + } + + do_action( 'cleared_users_subscriptions_from_order', $order ); + } + + /** + * Clear all subscriptions attached to an order when it's deleted. Also make sure + * all related scheduled actions are cancelled when deleting a susbcription. + * + * @param int $post_id The post ID of the WC Subscription or WC Order being trashed + * @since 1.0 + */ + public static function maybe_trash_subscription( $post_id ) { + + if ( 'shop_order' == get_post_type( $post_id ) ) { + + // delete subscription + foreach ( wcs_get_subscriptions_for_order( $post_id, array( 'order_type' => 'parent' ) ) as $subscription ) { + wp_trash_post( $subscription->id ); + } + } + } + + /** + * Make sure a subscription is cancelled before it is trashed or deleted + * + * @param int $post_id + * @since 2.0 + */ + public static function maybe_cancel_subscription( $post_id ) { + + if ( 'shop_subscription' == get_post_type( $post_id ) ) { + + $subscription = wcs_get_subscription( $post_id ); + + if ( ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) ) { + + $subscription->update_status( 'cancelled' ); + + } + } + } + + /** + * When WordPress trashes a post, it sets a '_wp_trash_meta_status' post meta value so that the post can + * be restored to its original status. However, when setting that value, it uses the 'post_status' of a + * $post variable in memory. If that status is changed on the 'wp_trash_post' or 'wp_delete_post' hooks, + * as is the case with a subscription, which is cancelled before being trashed if it is active or on-hold, + * then the '_wp_trash_meta_status' value will be incorrectly set to its status before being trashed. + * + * This function fixes that by setting '_wp_trash_meta_status' to 'wc-cancelled' whenever its former status + * is something that can not be restored. + * + * @param int $post_id + * @since 2.0 + */ + public static function fix_trash_meta_status( $post_id ) { + + if ( 'shop_subscription' == get_post_type( $post_id ) && ! in_array( get_post_meta( $post_id,'_wp_trash_meta_status', true ), array( 'wc-pending', 'wc-expired', 'wc-cancelled' ) ) ) { + update_post_meta( $post_id,'_wp_trash_meta_status', 'wc-cancelled' ); + } + } + + /** + * Trigger action hook after a subscription has been trashed. + * + * @param int $post_id + * @since 2.0 + */ + public static function trigger_subscription_trashed_hook( $post_id ) { + + if ( 'shop_subscription' == get_post_type( $post_id ) ) { + do_action( 'woocommerce_subscription_trashed', $post_id ); + } + } + + /** + * Takes a user ID and trashes any subscriptions that user has. + * + * @param int $user_id The ID of the user whose subscriptions will be trashed + * @since 2.0 + */ + public static function trash_users_subscriptions( $user_id ) { + + $subscriptions = wcs_get_users_subscriptions( $user_id ); + + if ( ! empty( $subscriptions ) ) { + + foreach ( $subscriptions as $subscription ) { + wp_delete_post( $subscription->id ); + } + } + } + + /** + * Takes a user ID and trashes any subscriptions that user has on any site in a WordPress network + * + * @param int $user_id The ID of the user whose subscriptions will be trashed + * @since 2.0 + */ + public static function trash_users_subscriptions_for_network( $user_id ) { + + $sites = get_blogs_of_user( $user_id ); + + if ( ! empty( $sites ) ) { + + foreach ( $sites as $site ) { + + switch_to_blog( $site->userblog_id ); + + self::trash_users_subscriptions( $user_id ); + + restore_current_blog(); + } + } + } + + /** + * Trigger action hook after a subscription has been deleted. + * + * @param int $post_id + * @since 2.0 + */ + public static function trigger_subscription_deleted_hook( $post_id ) { + + if ( 'shop_subscription' == get_post_type( $post_id ) ) { + do_action( 'woocommerce_subscription_deleted', $post_id ); + } + } + + /** + * Checks if the current request is by a user to change the status of their subscription, and if it is + * validate the subscription cancellation request and maybe processes the cancellation. + * + * @since 1.0 + * @deprecated 2.0 + */ + public static function maybe_change_users_subscription() { + _deprecated_function( __METHOD__, '2.0', 'WCS_User_Change_Status_Handler::maybe_change_users_subscription()' ); + WCS_User_Change_Status_Handler::maybe_change_users_subscription(); + } + + /** + * Check if a given subscription can be changed to a given a status. + * + * The function checks the subscription's current status and if the payment gateway used to purchase the + * subscription allows for the given status to be set via its API. + * + * @param string $new_status_or_meta The status or meta data you want to change th subscription to. Can be 'active', 'on-hold', 'cancelled', 'expired', 'trash', 'deleted', 'failed', 'new-payment-date' or some other value attached to the 'woocommerce_can_subscription_be_changed_to' filter. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @since 1.0 + */ + public static function can_subscription_be_changed_to( $new_status_or_meta, $subscription_key, $user_id = '' ) { + + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::can_be_updated_to( $new_status_or_meta )' ); + + if ( 'new-payment-date' == $new_status_or_meta ) { + _deprecated_argument( __METHOD__, '2.0', 'The "new-payment-date" parameter value is deprecated. Use WC_Subscription::can_date_be_updated( "next_payment" ) method instead.' ); + } elseif ( 'suspended' == $new_status_or_meta ) { + _deprecated_argument( __METHOD__, '2.0', 'The "suspended" parameter value is deprecated. Use "on-hold" instead.' ); + $new_status_or_meta = 'on-hold'; + } + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + switch ( $new_status_or_meta ) { + case 'new-payment-date' : + $subscription_can_be_changed = $subscription->can_date_be_updated( 'next_payment' ); + break; + case 'active' : + case 'on-hold' : + case 'cancelled' : + case 'expired' : + case 'trash' : + case 'deleted' : + case 'failed' : + default : + $subscription_can_be_changed = $subscription->can_be_updated_to( $new_status_or_meta ); + break; + } + } catch ( Exception $e ) { + $subscription_can_be_changed = false; + } + + return $subscription_can_be_changed; + } + + /* + * Subscription Getters & Property functions + */ + + /** + * Return an associative array of a given subscriptions details (if it exists). + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param deprecated don't use + * @return array Subscription details + * @since 1.1 + */ + public static function get_subscription( $subscription_key, $deprecated = null ) { + + if ( null != $deprecated ) { + _deprecated_argument( __METHOD__, '1.4', 'Second parameter is deprecated' ); + } + + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription( $subscription_id )' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $subscription = wcs_get_subscription_in_deprecated_structure( $subscription ); + } catch ( Exception $e ) { + $subscription = array(); + } + + return apply_filters( 'woocommerce_get_subscription', $subscription, $subscription_key, $deprecated ); + } + + /** + * Return an i18n'ified string for a given subscription status. + * + * @param string $status An subscription status of it's internal form. + * @return string A translated subscription status string for display. + * @since 1.2.3 + */ + public static function get_status_to_display( $status, $subscription_key = '', $user_id = 0 ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription_statuses()' ); + + switch ( $status ) { + case 'active' : + $status_string = _x( 'Active', 'Subscription status', 'woocommerce-subscriptions' ); + break; + case 'cancelled' : + $status_string = _x( 'Cancelled', 'Subscription status', 'woocommerce-subscriptions' ); + break; + case 'expired' : + $status_string = _x( 'Expired', 'Subscription status', 'woocommerce-subscriptions' ); + break; + case 'pending' : + $status_string = _x( 'Pending', 'Subscription status', 'woocommerce-subscriptions' ); + break; + case 'failed' : + $status_string = _x( 'Failed', 'Subscription status', 'woocommerce-subscriptions' ); + break; + case 'on-hold' : + case 'suspend' : // Backward compatibility + $status_string = _x( 'On-hold', 'Subscription status', 'woocommerce-subscriptions' ); + break; + default : + $status_string = apply_filters( 'woocommerce_subscriptions_custom_status_string', ucfirst( $status ), $subscription_key, $user_id ); + } + + return apply_filters( 'woocommerce_subscriptions_status_string', $status_string, $status, $subscription_key, $user_id ); + } + + /** + * Return an i18n'ified associative array of all possible subscription periods. + * + * @since 1.1 + * @deprecated 2.0 + */ + public static function get_subscription_period_strings( $number = 1, $period = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription_period_strings()' ); + return wcs_get_subscription_period_strings( $number, $period ); + } + + /** + * Return an i18n'ified associative array of all possible subscription periods. + * + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_subscription_period_interval_strings( $interval = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription_period_interval_strings()' ); + return wcs_get_subscription_period_interval_strings( $interval ); + } + + /** + * Returns an array of subscription lengths. + * + * PayPal Standard Allowable Ranges + * D – for days; allowable range is 1 to 90 + * W – for weeks; allowable range is 1 to 52 + * M – for months; allowable range is 1 to 24 + * Y – for years; allowable range is 1 to 5 + * + * @param subscription_period string (optional) One of day, week, month or year. If empty, all subscription ranges are returned. + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_subscription_ranges( $subscription_period = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription_ranges()' ); + return wcs_get_subscription_ranges( $subscription_period ); + } + + /** + * Returns an array of allowable trial periods. + * + * @see self::get_subscription_ranges() + * @param subscription_period string (optional) One of day, week, month or year. If empty, all subscription ranges are returned. + * @since 1.1 + * @deprecated 2.0 + */ + public static function get_subscription_trial_lengths( $subscription_period = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription_trial_lengths( $subscription_period )' ); + return wcs_get_subscription_trial_lengths( $subscription_period ); + } + + /** + * Return an i18n'ified associative array of all possible subscription trial periods. + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_subscription_trial_period_strings( $number = 1, $period = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscription_trial_period_strings( $number, $period )' ); + return wcs_get_subscription_trial_period_strings( $number, $period ); + } + + /** + * Return an i18n'ified associative array of all time periods allowed for subscriptions. + * + * @param string $form Either 'singular' for singular trial periods or 'plural'. + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_available_time_periods( $form = 'singular' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_available_time_periods( $form )' ); + return wcs_get_available_time_periods( $form ); + } + + /** + * Returns the string key for a subscription purchased in an order specified by $order_id + * + * @param order_id int The ID of the order in which the subscription was purchased. + * @param product_id int The ID of the subscription product. + * @return string The key representing the given subscription. + * @since 1.0 + */ + public static function get_subscription_key( $order_id, $product_id = '' ) { + + _deprecated_function( __METHOD__, '2.0', 'wcs_get_old_subscription_key( WC_Subscription $subscription )' ); + + // If we have a child renewal order, we need the parent order's ID + if ( wcs_order_contains_renewal( $order_id ) ) { + $order_id = WC_Subscriptions_Renewal_Order::get_parent_order_id( $order_id ); + } + + // Get the ID of the first order item in a subscription created by this order + if ( empty( $product_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'parent' ) ); + + foreach ( $subscriptions as $subscription ) { + $subscription_items = $subscription->get_items(); + if ( ! empty( $subscription_items ) ) { + break; + } + } + + if ( ! empty( $subscription_items ) ) { + $first_item = reset( $subscription_items ); + $product_id = WC_Subscriptions_Order::get_items_product_id( $first_item ); + } else { + $product_id = ''; + } + } + + $subscription_key = $order_id . '_' . $product_id; + + return apply_filters( 'woocommerce_subscription_key', $subscription_key, $order_id, $product_id ); + } + + /** + * Returns the number of failed payments for a given subscription. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @return int The number of outstanding failed payments on the subscription, if any. + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_subscriptions_failed_payment_count( $subscription_key, $user_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_failed_payment_count()' ); + return apply_filters( 'woocommerce_subscription_failed_payment_count', wcs_get_subscription_from_key( $subscription_key )->get_failed_payment_count(), $user_id, $subscription_key ); + } + + /** + * Returns the number of completed payments for a given subscription (including the intial payment). + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @return int The number of outstanding failed payments on the subscription, if any. + * @since 1.4 + * @deprecated 2.0 + */ + public static function get_subscriptions_completed_payment_count( $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_completed_payment_count()' ); + return apply_filters( 'woocommerce_subscription_completed_payment_count', wcs_get_subscription_from_key( $subscription_key )->get_completed_payment_count(), $subscription_key ); + } + + /** + * Takes a subscription key and returns the date on which the subscription is scheduled to expire + * or 0 if it is cancelled, expired, or never going to expire. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @since 1.1 + * @deprecated 2.0 + */ + public static function get_subscription_expiration_date( $subscription_key, $user_id = '', $type = 'mysql' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "end" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $expiration_date = ( 'mysql' == $type ) ? $subscription->get_date( 'end' ) : $subscription->get_time( 'end' ); + return apply_filters( 'woocommerce_subscription_expiration_date' , $expiration_date, $subscription_key, $user_id ); + } + + /** + * Updates a subscription's expiration date as scheduled in WP-Cron and in the subscription details array. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id (optional) The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param (optional) $next_payment string | int The date and time the next payment is due, either as MySQL formatted datetime string or a Unix timestamp. If empty, @see self::calculate_subscription_expiration_date() will be called. + * @return mixed If the expiration does not get set, returns false, otherwise it will return a MySQL datetime formatted string for the new date when the subscription will expire + * @since 1.2.4 + * @deprecated 2.0 + */ + public static function set_expiration_date( $subscription_key, $user_id = '', $expiration_date = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::update_dates( array( "end" => $expiration_date ) )' ); + if ( is_int( $expiration_date ) ) { + $expiration_date = date( 'Y-m-d H:i:s', $expiration_date ); + } + $subscription = wcs_get_subscription_from_key( $subscription_key ); + return apply_filters( 'woocommerce_subscriptions_set_expiration_date', $subscription->update_dates( array( 'end' => $expiration_date ) ), $subscription->get_date( 'end' ), $subscription_key, $user_id ); + } + + /** + * A subscription now either has an end date or it doesn't, there is no way to calculate it based on the original subsciption + * product (because a WC_Subscription object can have more than one product and syncing length with expiration date was both + * cumbersome and error prone). + * + * Takes a subscription key and calculates the date on which the subscription is scheduled to expire + * or 0 if it will never expire. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @since 1.1 + * @deprecated 2.0 + */ + public static function calculate_subscription_expiration_date( $subscription_key, $user_id = '', $type = 'mysql' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "end" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $expiration_date = ( 'mysql' == $type ) ? $subscription->get_date( 'end' ) : $subscription->get_time( 'end' ); + return apply_filters( 'woocommerce_subscription_calculated_expiration_date', $expiration_date, $subscription_key, $user_id ); + } + + /** + * Takes a subscription key and returns the date on which the next recurring payment is to be billed, if any. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @return mixed If there is no future payment set, returns 0, otherwise it will return a date of the next payment in the form specified by $type + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_next_payment_date( $subscription_key, $user_id = '', $type = 'mysql' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "next_payment" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $next_payment = ( 'mysql' == $type ) ? $subscription->get_date( 'next_payment' ) : $subscription->get_time( 'next_payment' ); + return apply_filters( 'woocommerce_subscription_next_payment_date', $next_payment, $subscription_key, $user_id, $type ); + } + + /** + * Clears the payment schedule for a subscription and schedules a new date for the next payment. + * + * If updating the an existing next payment date (instead of setting a new date, you should use @see self::update_next_payment_date() instead + * as it will validate the next payment date and update the WP-Cron lock. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id (optional) The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param (optional) $next_payment string | int The date and time the next payment is due, either as MySQL formatted datetime string or a Unix timestamp. If empty, @see self::calculate_next_payment_date() will be called. + * @return mixed If there is no future payment set, returns 0, otherwise it will return a MySQL datetime formatted string for the date of the next payment + * @since 1.2 + * @deprecated 2.0 + */ + public static function set_next_payment_date( $subscription_key, $user_id = '', $next_payment = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::update_dates( array( "next_payment" => $next_payment ) )' ); + + if ( is_int( $next_payment ) ) { + $next_payment = date( 'Y-m-d H:i:s', $next_payment ); + } + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + return apply_filters( 'woocommerce_subscription_set_next_payment_date', $subscription->update_dates( array( 'next_payment' => $next_payment ) ), $subscription->get_date( 'next_payment' ), $subscription_key, $user_id ); + } + + /** + * Takes a subscription key and returns the date on which the next recurring payment is to be billed, if any. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @return mixed If there is no future payment set, returns 0, otherwise it will return a date of the next payment in the form specified by $type + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_last_payment_date( $subscription_key, $user_id = '', $type = 'mysql' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "last_payment" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $last_payment_date = ( 'mysql' == $type ) ? $subscription->get_date( 'last_payment' ) : $subscription->get_time( 'last_payment' ); + return apply_filters( 'woocommerce_subscription_last_payment_date', $last_payment_date, $subscription_key, $user_id, $type ); + } + + /** + * Changes the transient used to safeguard against firing scheduled_subscription_payments during a payment period. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $lock_time The amount of time to lock for in seconds from now, the lock will be set 1 hour before this time + * @param int $user_id (optional) The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @since 1.2 + * @deprecated 2.0 + */ + public static function update_wp_cron_lock( $subscription_key, $lock_time, $user_id = '' ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Clears the payment schedule for a subscription and sets a net date + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id (optional) The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @return mixed If there is no future payment set, returns 0, otherwise it will return a date of the next payment of the type specified with $type + * @since 1.2 + * @deprecated 2.0 + */ + public static function calculate_next_payment_date( $subscription_key, $user_id = '', $type = 'mysql', $from_date = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::calculate_date( "next_payment" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $next_payment = $subscription->calculate_date( 'next_payment' ); + return ( 'mysql' == $type ) ? $next_payment : strtotime( $next_payment ); + } + + /** + * Takes a subscription key and returns the date on which the trial for the subscription ended or is going to end, if any. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @return mixed If the subscription has no trial period, returns 0, otherwise it will return the date the trial period ends or ended in the form specified by $type + * @since 1.2 + */ + public static function get_trial_expiration_date( $subscription_key, $user_id = '', $type = 'mysql' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "trial_end" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $trial_end_date = ( 'mysql' == $type ) ? $subscription->get_date( 'trial_end' ) : $subscription->get_time( 'trial_end' ); + return apply_filters( 'woocommerce_subscription_trial_expiration_date', $trial_end_date, $subscription_key, $user_id, $type ); + } + + /** + * Updates the trial expiration date as scheduled in WP-Cron and in the subscription details array. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id (optional) The ID of the user who owns the subscription. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param (optional) $next_payment string | int The date and time the next payment is due, either as MySQL formatted datetime string or a Unix timestamp. If empty, @see self::calculate_next_payment_date() will be called. + * @return mixed If the trial expiration does not get set, returns false, otherwise it will return a MySQL datetime formatted string for the new date when the trial will expire + * @since 1.2.4 + */ + public static function set_trial_expiration_date( $subscription_key, $user_id = '', $trial_expiration_date = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::update_dates( array( "trial_end" => $expiration_date ) )' ); + if ( is_int( $trial_expiration_date ) ) { + $trial_expiration_date = date( 'Y-m-d H:i:s', $trial_expiration_date ); + } + $subscription = wcs_get_subscription_from_key( $subscription_key ); + return apply_filters( 'woocommerce_subscriptions_set_trial_expiration_date', $subscription->update_dates( array( 'trial_end' => $trial_expiration_date ) ), $subscription->get_date( 'trial_end' ), $subscription_key, $user_id ); + } + + /** + * Takes a subscription key and calculates the date on which the subscription's trial should end + * or 0 if no trial is set. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @since 1.1 + */ + public static function calculate_trial_expiration_date( $subscription_key, $user_id = '', $type = 'mysql' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::calculate_date( "trial_end" )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $trial_end = $subscription->calculate_date( 'trial_end' ); + $trial_end = ( 'mysql' == $type ) ? $trial_end : strtotime( $trial_end ); + return apply_filters( 'woocommerce_subscription_calculated_trial_expiration_date' , $trial_end, $subscription_key, $user_id ); + } + + /** + * Takes a subscription key and returns the user who owns the subscription (based on the order ID in the subscription key). + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @return int The ID of the user who owns the subscriptions, or 0 if no user can be found with the subscription + * @since 1.2 + */ + public static function get_user_id_from_subscription_key( $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_user_id()' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + return $subscription->get_user_id(); + } + + /** + * Checks if a subscription requires manual payment because the payment gateway used to purchase the subscription + * did not support automatic payments at the time of the subscription sign up. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @return bool | null True if the subscription exists and requires manual payments, false if the subscription uses automatic payments, null if the subscription doesn't exist. + * @since 1.2 + * @deprecated 2.0 + */ + public static function requires_manual_renewal( $subscription_key, $user_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::is_manual()' ); + return wcs_get_subscription_from_key( $subscription_key )->is_manual(); + } + + /** + * Checks if a subscription has an unpaid renewal order. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @return bool True if the subscription has an unpaid renewal order, false if the subscription has no unpaid renewal orders. + * @since 1.2 + */ + public static function subscription_requires_payment( $subscription_key, $user_id ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::needs_payment()' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + return apply_filters( 'woocommerce_subscription_requires_payment', $subscription->needs_payment(), wcs_get_subscription_in_deprecated_structure( $subscription ), $subscription_key, $user_id ); + } + + /* + * User API Functions + */ + + /** + * Check if a user owns a subscription, as specified with $subscription_key. + * + * If no user is specified, the currently logged in user will be used. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id (optional) int The ID of the user to check against. Defaults to the currently logged in user. + * @return bool True if the user has the subscription (or any subscription if no subscription specified), otherwise false. + * @since 1.3 + * @deprecated 2.0 + */ + public static function user_owns_subscription( $subscription_key, $user_id = 0 ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions::get_user_id()' ); + + if ( 0 === $user_id || empty( $user_id ) ) { + $user_id = get_current_user_id(); + } + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->get_user_id() == $user_id ) { + $owns_subscription = true; + } else { + $owns_subscription = false; + } + + return apply_filters( 'woocommerce_user_owns_subscription', $owns_subscription, $subscription_key, $user_id ); + } + + /** + * Check if a user has a subscription, optionally specified with $product_id. + * + * @param int $user_id (optional) The id of the user whose subscriptions you want. Defaults to the currently logged in user. + * @param product_id int (optional) The ID of a subscription product. + * @param status string (optional) A subscription status to check against. For example, for a $status of 'active', a subscriber must have an active subscription for a return value of true. + * @return bool True if the user has the subscription (or any subscription if no subscription specified), otherwise false. + * @version 1.3.5 + */ + public static function user_has_subscription( $user_id = 0, $product_id = '', $status = 'any' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_user_has_subscription()' ); + return wcs_user_has_subscription( $user_id, $product_id, $status ); + } + + /** + * Gets all the active and inactive subscriptions for all users. + * + * @return array An associative array containing all users with subscriptions and the details of their subscriptions: 'user_id' => $subscriptions + * @since 1.0 + */ + public static function get_all_users_subscriptions() { + _deprecated_function( __METHOD__, '2.0' ); + + foreach ( get_users() as $user ) { + foreach ( wcs_get_users_subscriptions( $user->ID ) as $subscription ) { + $subscriptions_in_old_format[ wcs_get_old_subscription_key( $subscription ) ] = wcs_get_subscription_in_deprecated_structure( $subscription ); + } + } + + return apply_filters( 'woocommerce_all_users_subscriptions', $subscriptions_in_old_format ); + } + + /** + * Gets all the active and inactive subscriptions for a user, as specified by $user_id + * + * @param int $user_id (optional) The id of the user whose subscriptions you want. Defaults to the currently logged in user. + * @param array $order_ids (optional) An array of post_ids of WC_Order objects as a way to get only subscriptions for certain orders. Defaults to null, which will return subscriptions for all orders. + * @since 1.0 + */ + public static function get_users_subscriptions( $user_id = 0, $order_ids = array() ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_users_subscriptions( $user_id )' ); + + $subscriptions_in_old_format = array(); + + foreach ( wcs_get_users_subscriptions( $user_id ) as $subscription ) { + $subscriptions_in_old_format[ wcs_get_old_subscription_key( $subscription ) ] = wcs_get_subscription_in_deprecated_structure( $subscription ); + } + + return apply_filters( 'woocommerce_users_subscriptions', $subscriptions_in_old_format, $user_id ); + } + + /** + * Gets all the subscriptions for a user that have been trashed, as specified by $user_id + * + * @param int $user_id (optional) The id of the user whose subscriptions you want. Defaults to the currently logged in user. + * @since 1.0 + */ + public static function get_users_trashed_subscriptions( $user_id = '' ) { + + $subscriptions = self::get_users_subscriptions( $user_id ); + + foreach ( $subscriptions as $key => $subscription ) { + if ( 'trash' != $subscription['status'] ) { + unset( $subscriptions[ $key ] ); + } + } + + return apply_filters( 'woocommerce_users_trashed_subscriptions', $subscriptions, $user_id ); + } + + /** + * A convenience wrapper to assign the inactive subscriber role to a user. + * + * @param int $user_id The id of the user whose role should be changed + * @since 1.2 + * @deprecated 2.0 + */ + public static function make_user_inactive( $user_id ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_make_user_inactive()' ); + wcs_make_user_inactive( $user_id ); + } + + /** + * A convenience wrapper to assign the cancelled subscriber role to a user. + * + * Hooked to 'subscription_end_of_prepaid_term' hook. + * + * @param int $user_id The id of the user whose role should be changed + * @since 1.3.2 + * @deprecated 2.0 + */ + public static function maybe_assign_user_cancelled_role( $user_id ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_maybe_make_user_inactive()' ); + wcs_maybe_make_user_inactive( $user_id ); + } + + /** + * A convenience wrapper for changing a users role. + * + * @param int $user_id The id of the user whose role should be changed + * @param string $role_name Either a WordPress role or one of the WCS keys: 'default_subscriber_role' or 'default_cancelled_role' + * @since 1.0 + * @deprecated 2.0 + */ + public static function update_users_role( $user_id, $role_name ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_update_users_role()' ); + wcs_update_users_role( $user_id, $role_name ); + } + + /** + * Marks a customer as a paying customer when their subscription is activated. + * + * A wrapper for the @see woocommerce_paying_customer() function. + * + * @param int $order_id The id of the order for which customers should be pulled from and marked as paying. + * @since 1.0 + * @deprecated 2.0 + */ + public static function mark_paying_customer( $order ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + update_user_meta( $order->user_id, 'paying_customer', 1 ); + } + + /** + * Unlike someone making a once-off payment, a subscriber can cease to be a paying customer. This function + * changes a user's status to non-paying. + * + * Deprecated as orders now take care of the customer's status as paying or not paying + * + * @param object $order The order for which a customer ID should be pulled from and marked as paying. + * @since 1.0 + * @deprecated 2.0 + */ + public static function mark_not_paying_customer( $order ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + if ( $order->user_id > 0 ) { + update_user_meta( $order->user_id, 'paying_customer', 0 ); + } + } + + /** + * Return a link for subscribers to change the status of their subscription, as specified with $status parameter + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + * @deprecated 2.0 + */ + public static function get_users_change_status_link( $subscription_key, $status ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_users_change_status_link( $subscription_id, $status )' ); + + if ( 'suspended' == $status ) { + _deprecated_argument( __METHOD__, '2.0', 'The "suspended" parameter value is deprecated. Use "on-hold" instead.' ); + $status = 'on-hold'; + } + + $subscription_id = wcs_get_subscription_id_from_key( $subscription_key ); + + $current_status = ''; + $subscription = wcs_get_subscription( $subscription_id ); + if ( $subscription instanceof WC_Subscription ) { + $current_status = $subscription->get_status(); + } + + return apply_filters( 'woocommerce_subscriptions_users_action_link', wcs_get_users_change_status_link( $subscription_id, $status, $current_status ), $subscription_key, $status ); + } + + /** + * Change a subscription's next payment date. + * + * @param mixed $new_payment_date Either a MySQL formatted Date/time string or a Unix timestamp. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @param int $user_id The id of the user who purchased the subscription + * @param string $timezone Either 'server' or 'user' to describe the timezone of the $new_payment_date. + * @since 1.2 + * @deprecated 2.0 + */ + public static function update_next_payment_date( $new_payment_date, $subscription_key, $user_id = '', $timezone = 'server' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::update_dates( array( "next_payment" => $new_payment_date ) )' ); + + $new_payment_timestamp = ( is_numeric( $new_payment_date ) ) ? $new_payment_date : strtotime( $new_payment_date ); + + // The date needs to be converted to GMT/UTC + if ( 'server' != $timezone ) { + $new_payment_timestamp = $new_payment_timestamp - ( get_option( 'gmt_offset' ) * 3600 ); + } + + $new_payment_date = date( 'Y-m-d H:i:s', $new_payment_timestamp ); + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + try { + $subscription->update_dates( array( 'next_payment' => $new_payment_date ) ); + $response = $subscription->get_time( 'next_payment' ); + + } catch ( Exception $e ) { + $response = new WP_Error( 'invalid-date', $e->getMessage() ); + } + + return $response; + } + + /* + * Helper Functions + */ + + /** + * Because neither PHP nor WP include a real array merge function that works recursively. + * + * @since 1.0 + */ + public static function array_merge_recursive_for_real( $first_array, $second_array ) { + + $merged = $first_array; + + if ( is_array( $second_array ) ) { + foreach ( $second_array as $key => $val ) { + if ( is_array( $second_array[ $key ] ) ) { + $merged[ $key ] = ( isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) ? self::array_merge_recursive_for_real( $merged[ $key ], $second_array[ $key ] ) : $second_array[ $key ]; + } else { + $merged[ $key ] = $val; + } + } + } + + return $merged; + } + + /** + * Takes a total and calculates the recurring proportion of that based on $proportion and then fixes any rounding bugs to + * make sure the totals add up. + * + * Used mainly to calculate the recurring amount from a total which may also include a sign up fee. + * + * @param float $total The total amount + * @since 1.2 + * @return float $proportion A proportion of the total (e.g. 0.5 is half of the total) + */ + public static function get_amount_from_proportion( $total, $proportion ) { + + $sign_up_fee_proprotion = 1 - $proportion; + + $sign_up_total = round( $total * $sign_up_fee_proprotion, 2 ); + $recurring_amount = round( $total * $proportion, 2 ); + + // Handle any rounding bugs + if ( $sign_up_total + $recurring_amount != $total ) { + $recurring_amount = $recurring_amount - ( $sign_up_total + $recurring_amount - $total ); + } + + return $recurring_amount; + } + + /** + * Creates a subscription price string from an array of subscription details. For example, ""$5 / month for 12 months". + * + * @param array $subscription_details A set of name => value pairs for the subscription details to include in the string. Available keys: + * 'initial_amount': The upfront payment for the subscription, including sign up fees, as a string from the @see woocommerce_price(). Default empty string (no initial payment) + * 'initial_description': The word after the initial payment amount to describe the amount. Examples include "now" or "initial payment". Defaults to "up front". + * 'recurring_amount': The amount charged per period. Default 0 (no recurring payment). + * 'subscription_interval': How regularly the subscription payments are charged. Default 1, meaning each period e.g. per month. + * 'subscription_period': The temporal period of the subscription. Should be one of {day|week|month|year} as used by @see self::get_subscription_period_strings() + * 'subscription_length': The total number of periods the subscription should continue for. Default 0, meaning continue indefinitely. + * 'trial_length': The total number of periods the subscription trial period should continue for. Default 0, meaning no trial period. + * 'trial_period': The temporal period for the subscription's trial period. Should be one of {day|week|month|year} as used by @see self::get_subscription_period_strings() + * @since 1.2 + * @deprecated 2.0 + * @return float $proportion A proportion of the total (e.g. 0.5 is half of the total) + */ + public static function get_subscription_price_string( $subscription_details ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_price_string()' ); + return wcs_price_string( $subscription_details ); + } + + + /** + * Copy of the WordPress "touch_time" template function for use with a variety of different times + * + * @param array $args A set of name => value pairs to customise how the function operates. Available keys: + * 'date': (string) the date to display in the selector in MySQL format ('Y-m-d H:i:s'). Required. + * 'tab_index': (int) the tab index for the element. Optional. Default 0. + * 'multiple': (bool) whether there will be multiple instances of the element on the same page (determines whether to include an ID or not). Default false. + * 'echo': (bool) whether to return and print the element or simply return it. Default true. + * 'include_time': (bool) whether to include a specific time for the selector. Default true. + * 'include_year': (bool) whether to include a the year field. Default true. + * 'include_buttons': (bool) whether to include submit buttons on the selector. Default true. + * @since 1.2 + */ + public static function touch_time( $args = array() ) { + global $wp_locale; + + $args = wp_parse_args( $args, array( + 'date' => true, + 'tab_index' => 0, + 'multiple' => false, + 'echo' => true, + 'include_time' => true, + 'include_buttons' => true, + ) + ); + + if ( empty( $args['date'] ) ) { + return; + } + + $tab_index_attribute = ( (int) $args['tab_index'] > 0 ) ? ' tabindex="' . $args['tab_index'] . '"' : ''; + + $month = mysql2date( 'n', $args['date'], false ); + + $month_input = ''; + + $day_input = ''; + $year_input = ''; + + if ( $args['include_time'] ) { + + $hour_input = ''; + $minute_input = ''; + + // translators: all fields are full html nodes: 1$: month input, 2$: day input, 3$: year input, 4$: hour input, 5$: minute input. Change the order if you'd like + $touch_time = sprintf( __( '%1$s%2$s, %3$s @ %4$s : %5$s', 'woocommerce-subscriptions' ), $month_input, $day_input, $year_input, $hour_input, $minute_input ); + + } else { + // translators: all fields are full html nodes: 1$: month input, 2$: day input, 3$: year input. Change the order if you'd like + $touch_time = sprintf( __( '%1$s%2$s, %3$s', 'woocommerce-subscriptions' ), $month_input, $day_input, $year_input ); + } + + if ( $args['include_buttons'] ) { + $touch_time .= '

    '; + $touch_time .= '' . __( 'Change', 'woocommerce-subscriptions' ) . ''; + $touch_time .= '' . _x( 'Cancel', 'an action on a subscription', 'woocommerce-subscriptions' ) . ''; + $touch_time .= '

    '; + } + + $allowed_html = array( + 'select' => array( + 'id' => array(), + 'name' => array(), + 'tabindex' => array(), + ), + 'option' => array( + 'value' => array(), + 'selected' => array(), + ), + 'input' => array( + 'type' => array(), + 'id' => array(), + 'name' => array(), + 'value' => array(), + 'size' => array(), + 'tabindex' => array(), + 'maxlength' => array(), + 'autocomplete' => array(), + ), + 'p' => array(), + 'a' => array( + 'href' => array(), + 'title' => array(), + 'class' => array(), + ), + ); + + if ( $args['echo'] ) { + echo wp_kses( $touch_time, $allowed_html ); + } + + return $touch_time; + } + + /** + * If a gateway doesn't manage payment schedules, then we should suspend the subscription until it is paid (i.e. for manual payments + * or token gateways like Stripe). If the gateway does manage the scheduling, then we shouldn't suspend the subscription because a + * gateway may use batch processing on the time payments are charged and a subscription could end up being incorrectly suspended. + * + * @param int $user_id The id of the user whose subscription should be put on-hold. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.2.5 + * @deprecated 2.0 + */ + public static function maybe_put_subscription_on_hold( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::update_status()' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( 'on-hold' ) ) { + return false; + } + } catch ( Exception $e ) { + return false; + } + + // If the subscription is using manual payments, the gateway isn't active or it manages scheduled payments + if ( 0 == $subscription->get_total() || $subscription->is_manual() || empty( $subscription->payment_method ) || ! $subscription->payment_method_supports( 'gateway_scheduled_payments' ) ) { + $subscription->update_status( 'on-hold', _x( 'Subscription renewal payment due:', 'used in order note as reason for why subscription status changed', 'woocommerce-subscriptions' ) ); + } + } + + /** + * Check if the subscription needs to use the failed payment process to repair its status after it incorrectly expired due to a date migration + * bug in upgrade process for 2.0.0 of Subscriptions (i.e. not 2.0.1 or newer). See WCS_Repair_2_0_2::maybe_repair_status() for more details. + * + * @param int $subscription_id The ID of a 'shop_subscription' post + * @since 2.0.2 + */ + public static function maybe_process_failed_renewal_for_repair( $subscription_id ) { + + if ( 'true' == get_post_meta( $subscription_id, '_wcs_repaired_2_0_2_needs_failed_payment', true ) ) { + + $subscription = wcs_get_subscription( $subscription_id ); + + // Always put the subscription on hold in case something goes wrong while trying to process renewal + $subscription->update_status( 'on-hold', _x( 'Subscription renewal payment due:', 'used in order note as reason for why subscription status changed', 'woocommerce-subscriptions' ) ); + + // Create a renewal order to record the failed payment which can then be used by the customer to reactivate the subscription + $renewal_order = wcs_create_renewal_order( $subscription ); + + // Mark the payment as failed so the customer can login to fix up the failed payment + $subscription->payment_failed(); + + // Only force the failed payment once + update_post_meta( $subscription_id, '_wcs_repaired_2_0_2_needs_failed_payment', 'false' ); + + // We've already processed the renewal + remove_action( 'woocommerce_scheduled_subscription_payment', __CLASS__ . '::prepare_renewal' ); + remove_action( 'woocommerce_scheduled_subscription_payment', 'WC_Subscriptions_Payment_Gateways::gateway_scheduled_subscription_payment', 10, 1 ); + } + } + + /* Deprecated Functions */ + + /** + * @deprecated 1.1 + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function can_subscription_be_cancelled( $subscription_key, $user_id = '' ) { + _deprecated_function( __METHOD__, '1.1', __CLASS__ . '::can_subscription_be_changed_to( "cancelled", $subscription_key, $user_id )' ); + $subscription_can_be_cancelled = self::can_subscription_be_changed_to( 'cancelled', $subscription_key, $user_id ); + + return apply_filters( 'woocommerce_subscription_can_be_cancelled', $subscription_can_be_cancelled, $subscription, $order ); + } + + /** + * @deprecated 1.1 + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function get_users_cancellation_link( $subscription_key ) { + _deprecated_function( __METHOD__, '1.1', __CLASS__ . '::get_users_cancellation_link( $subscription_key, "cancel" )' ); + return apply_filters( 'woocommerce_subscriptions_users_cancellation_link', self::get_users_change_status_link( $subscription_key, 'cancel' ), $subscription_key ); + } + + /** + * @deprecated 1.1 + * @since 1.0 + */ + public static function maybe_cancel_users_subscription() { + _deprecated_function( __METHOD__, '1.1', __CLASS__ . '::maybe_change_users_subscription()' ); + self::maybe_change_users_subscription(); + } + + /** + * @deprecated 1.1 + * @param int $user_id The ID of the user who owns the subscriptions. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function get_failed_payment_count( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '1.1', __CLASS__ . '::get_subscriptions_failed_payment_count( $subscription_key, $user_id )' ); + return self::get_subscriptions_failed_payment_count( $subscription_key, $user_id ); + } + + /** + * Deprecated in favour of a more correctly named @see maybe_reschedule_subscription_payment() + * + * @deprecated 1.1.5 + * @since 1.0 + */ + public static function reschedule_subscription_payment( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '1.1.5', __CLASS__ . '::maybe_reschedule_subscription_payment( $user_id, $subscription_key )' ); + self::maybe_reschedule_subscription_payment( $user_id, $subscription_key ); + } + + + /** + * Suspended a single subscription on a users account by placing it in the "suspended" status. + * + * Subscriptions version 1.2 replaced the "suspended" status with the "on-hold" status to match WooCommerce core. + * + * @param int $user_id The id of the user whose subscription should be suspended. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @deprecated 1.2 + * @since 1.0 + */ + public static function suspend_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ . '::put_subscription_on_hold( $user_id, $subscription_key )' ); + self::put_subscription_on_hold( $user_id, $subscription_key ); + } + + + /** + * Suspended all the subscription products in an order. + * + * Subscriptions version 1.2 replaced the "suspended" status with the "on-hold" status to match WooCommerce core. + * + * @param WC_Order|int $order The order or ID of the order for which subscriptions should be marked as activated. + * @deprecated 1.2 + * @since 1.0 + */ + public static function suspend_subscriptions_for_order( $order ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ . '::put_subscription_on_hold_for_order( $order )' ); + self::put_subscription_on_hold_for_order( $order ); + } + + + /** + * Gets a specific subscription for a user, as specified by $subscription_key + * + * Subscriptions version 1.4 moved subscription details out of user meta and into item meta, meaning it can be accessed + * efficiently without a user ID. + * + * @param int $user_id (optional) The id of the user whose subscriptions you want. Defaults to the currently logged in user. + * @param string $subscription_key A subscription key of the form created by @see self::subscription_key() + * @deprecated 1.4 + * @since 1.0 + */ + public static function get_users_subscription( $user_id = 0, $subscription_key ) { + _deprecated_function( __METHOD__, '1.4', __CLASS__ . '::get_subscription( $subscription_key )' ); + return apply_filters( 'woocommerce_users_subscription', self::get_subscription( $subscription_key ), $user_id, $subscription_key ); + } + + + /** + * Removed a specific subscription for a user, as specified by $subscription_key, but as subscriptions are no longer stored + * against a user and are instead stored against the order, this is no longer required (changing the user on the order effectively + * performs the same thing without requiring the subscription to have any changes). + * + * @param int $user_id (optional) The id of the user whose subscriptions you want. Defaults to the currently logged in user. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @deprecated 1.4 + * @since 1.0 + */ + public static function remove_users_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '1.4' ); + } + + /** + * When a scheduled subscription payment hook is fired, automatically process the subscription payment + * if the amount is for $0 (and therefore, there is no payment to be processed by a gateway, and likely + * no gateway used on the initial order). + * + * If a subscription has a $0 recurring total and is not already active (after being actived by something else + * handling the 'scheduled_subscription_payment' with the default priority of 10), then this function will call + * @see self::process_subscription_payment() to reactive the subscription, generate a renewal order etc. + * + * @param int $user_id The id of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.3.2 + * @deprecated 2.0 + */ + public static function maybe_process_subscription_payment( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::prepare_renewal( $subscription_id )' ); + self::prepare_renewal( wcs_get_subscription_id_from_key( $subscription_key ) ); + } + + /** + * Return a link for subscribers to change the status of their subscription, as specified with $status parameter + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function current_user_can_suspend_subscription( $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_can_user_put_subscription_on_hold( $subscription, $user )' ); + return wcs_can_user_put_subscription_on_hold( wcs_get_subscription_from_key( $subscription_key ) ); + } + + /** + * Return a multi-dimensional associative array of subscriptions with a certain value, grouped by user ID. + * + * A slow PHP based search routine which can't use the speed of MySQL because subscription details. If you + * know the key for the value you are search by, use @see self::get_subscriptions() for better performance. + * + * @param string $search_query The query to search the database for. + * @return array Subscription details + * @since 1.1 + * @deprecated 2.0 + */ + public static function search_subscriptions( $search_query ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscriptions()' ); + global $wpdb; + + $subscriptions_to_search = self::get_all_users_subscriptions(); + + $subscriptions_found = array(); + + $search_terms = explode( ' ', $search_query ); + + foreach ( $subscriptions_to_search as $user_id => $subscriptions ) { + + $user = get_user_by( 'id', $user_id ); + + if ( false === $user || ! is_object( $user ) ) { + continue; + } + + $user = $user->data; + + foreach ( $search_terms as $search_term ) { + + // If the search query is found in the user's details, add all of their subscriptions, otherwise add only subscriptions with a matching item + if ( false !== stripos( $user->user_nicename, $search_term ) || false !== stripos( $user->display_name, $search_term ) ) { + $subscriptions_found[ $user_id ] = $subscriptions; + } elseif ( false !== stripos( $user->user_login, $search_term ) || false !== stripos( $user->user_email, $search_term ) ) { + $subscriptions_found[ $user_id ] = $subscriptions; + } else { + foreach ( $subscriptions as $subscription_key => $subscription ) { + + $product_title = get_the_title( $subscription['product_id'] ); + + if ( in_array( $search_term, $subscription, true ) || false != preg_match( "/$search_term/i", $product_title ) ) { + $subscriptions_found[ $user_id ][ $subscription_key ] = $subscription; + } + } + } + } + } + + return apply_filters( 'woocommerce_search_subscriptions', $subscriptions_found, $search_query ); + } + + /** + * Marks a single subscription as active on a users account. + * + * @param int $user_id The id of the user whose subscription is to be activated. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function activate_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( 'active' ) ) { + return false; + } + } catch ( Exception $e ) { + return false; + } + + if ( ! $subscription->has_status( 'pending' ) && ! $subscription->can_be_updated_to( 'active' ) ) { + + do_action( 'unable_to_activate_subscription', $user_id, $subscription_key ); + + $activated_subscription = false; + + } else { + + $subscription->update_status( 'active' ); + + do_action( 'activated_subscription', $user_id, $subscription_key ); + + $activated_subscription = true; + + } + + return $activated_subscription; + } + + /** + * Changes a single subscription from on-hold to active on a users account. + * + * @param int $user_id The id of the user whose subscription is to be activated. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function reactivate_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( false !== self::activate_subscription( $user_id, $subscription_key ) ) { + do_action( 'reactivated_subscription', $user_id, $subscription_key ); + } + } + + /** + * Suspends a single subscription on a users account by placing it in the "on-hold" status. + * + * @param int $user_id The id of the user whose subscription should be put on-hold. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function put_subscription_on_hold( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::update_status( "on-hold" )' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( 'on-hold' ) ) { + return false; + } + } catch ( Exception $e ) { + return false; + } + + if ( ! $subscription->can_be_updated_to( 'on-hold' ) ) { + + do_action( 'unable_to_put_subscription_on-hold', $user_id, $subscription_key ); + do_action( 'unable_to_suspend_subscription', $user_id, $subscription_key ); + + } else { + + $subscription->update_status( 'on-hold' ); + + do_action( 'subscription_put_on-hold', $user_id, $subscription_key ); + // Backward, backward compatibility + do_action( 'suspended_subscription', $user_id, $subscription_key ); + } + } + + /** + * Cancels a single subscription on a users account. + * + * @param int $user_id The id of the user whose subscription should be cancelled. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function cancel_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscriptions::cancel_order()' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( array( 'pending-cancel', 'cancelled' ) ) ) { + return false; + } + } catch ( Exception $e ) { + return false; + } + + if ( ! $subscription->can_be_updated_to( 'cancelled' ) ) { + + do_action( 'unable_to_cancel_subscription', $user_id, $subscription_key ); + + } else { + + $subscription->update_status( 'cancelled' ); + + do_action( 'cancelled_subscription', $user_id, $subscription_key ); + + } + } + + /** + * Sets a single subscription on a users account to be 'on-hold' and keeps a record of the failed sign up on an order. + * + * @param int $user_id The id of the user whose subscription should be cancelled. + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function failed_subscription_signup( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( 'on-hold' ) ) { + return false; + } + } catch ( Exception $e ) { + return false; + } + + // Place the subscription on-hold + $subscription->update_status( 'on-hold' ); + + // Log failure on order + // translators: placeholder is subscription ID + $subscription->order->add_order_note( sprintf( __( 'Failed sign-up for subscription %s.', 'woocommerce-subscriptions' ), $subscription->id ) ); + + do_action( 'subscription_sign_up_failed', $user_id, $subscription_key ); + } + + /** + * Trashes a single subscription on a users account. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.0 + */ + public static function trash_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'wp_trash_post()' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( 'trash' ) ) { + return false; + } + } catch ( Exception $e ) { + return false; + } + + if ( ! $subscription->can_be_updated_to( 'cancelled' ) ) { + + do_action( 'unable_to_trash_subscription', $user_id, $subscription_key ); + + } else { + + // Run all cancellation related functions on the subscription + if ( ! $subscription->has_status( array( 'cancelled', 'expired', 'trash' ) ) ) { + $subscription->update_status( 'cancelled' ); + } + + wp_trash_post( $subscription->id, true ); + + do_action( 'subscription_trashed', $user_id, $subscription_key ); + } + } + + /** + * Permanently deletes a single subscription on a users account. + * + * @param int $user_id The ID of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.2 + */ + public static function delete_subscription( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'wp_delete_post()' ); + + try { + $subscription = wcs_get_subscription_from_key( $subscription_key ); + } catch ( Exception $e ) { + return false; + } + + if ( ! $subscription->can_be_updated_to( 'deleted' ) && ! $subscription->can_be_updated_to( 'cancelled' ) ) { + + do_action( 'unable_to_delete_subscription', $user_id, $subscription_key ); + + } else { + + // Run all cancellation related functions on the subscription + if ( ! $subscription->has_status( array( 'cancelled', 'expired', 'trash' ) ) ) { + $subscription->update_status( 'cancelled' ); + } + + wp_delete_post( $subscription->id, true ); + + do_action( 'subscription_deleted', $user_id, $subscription_key, $subscription, $item ); + } + } + + + /** + * Processes an ajax request to change a subscription's next payment date. + * + * Deprecated because editing a subscription's next payment date is now done from the Edit Subscription screen. + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function ajax_update_next_payment_date() { + _deprecated_function( __METHOD__, '2.0', 'wp_delete_post()' ); + + $response = array( 'status' => 'error' ); + + if ( ! wp_verify_nonce( $_POST['wcs_nonce'], 'woocommerce-subscriptions' ) ) { + + $response['message'] = '
    ' . __( 'Invalid security token, please reload the page and try again.', 'woocommerce-subscriptions' ) . '
    '; + + } elseif ( ! current_user_can( 'manage_woocommerce' ) ) { + + $response['message'] = '
    ' . __( 'Only store managers can edit payment dates.', 'woocommerce-subscriptions' ) . '
    '; + + } elseif ( empty( $_POST['wcs_day'] ) || empty( $_POST['wcs_month'] ) || empty( $_POST['wcs_year'] ) ) { + + $response['message'] = '
    ' . __( 'Please enter all date fields.', 'woocommerce-subscriptions' ) . '
    '; + + } else { + + $new_payment_date = sprintf( '%s-%s-%s %s', (int) $_POST['wcs_year'], zeroise( (int) $_POST['wcs_month'], 2 ), zeroise( (int) $_POST['wcs_day'], 2 ), date( 'H:i:s', current_time( 'timestamp' ) ) ); + $new_payment_timestamp = self::update_next_payment_date( $new_payment_date, $_POST['wcs_subscription_key'], self::get_user_id_from_subscription_key( $_POST['wcs_subscription_key'] ), 'user' ); + + if ( is_wp_error( $new_payment_timestamp ) ) { + + $response['message'] = sprintf( '
    %s
    ', $new_payment_timestamp->get_error_message() ); + + } else { + + $new_payment_timestamp_user_time = $new_payment_timestamp + ( get_option( 'gmt_offset' ) * 3600 ); // The timestamp is returned in server time + + $time_diff = $new_payment_timestamp - gmdate( 'U' ); + + if ( $time_diff > 0 && $time_diff < 7 * 24 * 60 * 60 ) { + // translators: placeholder is human time diff (e.g. "3 weeks") + $date_to_display = sprintf( __( 'In %s', 'woocommerce-subscriptions' ), human_time_diff( gmdate( 'U' ), $new_payment_timestamp ) ); + } else { + $date_to_display = date_i18n( wc_date_format(), $new_payment_timestamp_user_time ); + } + + $response['status'] = 'success'; + $response['message'] = '
    ' . __( 'Date Changed', 'woocommerce-subscriptions' ) . '
    '; + $response['dateToDisplay'] = $date_to_display; + $response['timestamp'] = $new_payment_timestamp_user_time; + + } + } + + echo wcs_json_encode( $response ); + + exit(); + } + + /** + * WP-Cron occasionally gets itself into an infinite loop on scheduled events, this function is + * designed to create a non-cron related safeguard against payments getting caught up in such a loop. + * + * When the scheduled subscription payment hook is fired by WP-Cron, this function is attached before + * any other to make sure the hook hasn't already fired for this period. + * + * A transient is used to keep a record of any payment for each period. The transient expiration is + * set to one billing period in the future, minus 1 hour, if there is a future payment due, otherwise, + * it is set to 23 hours in the future. This later option provides a safeguard in case a subscription's + * data is corrupted and the @see self::calculate_next_payment_date() is returning an + * invalid value. As no subscription can charge a payment more than once per day, the 23 hours is a safe + * throttle period for billing that still removes the possibility of a catastrophic failure (payments + * firing every few seconds until a credit card is maxed out). + * + * The transient keys use both the user ID and subscription key to ensure it is unique per subscription + * (even on multisite) + * + * @param int $user_id The id of the user who purchased the subscription + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.1.2 + * @deprecated 2.0 + */ + public static function safeguard_scheduled_payments( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * When a subscription payment hook is fired, reschedule the hook to run again on the + * time/date of the next payment (if any). + * + * WP-Cron's built in wp_schedule_event() function can not be used because the recurrence + * must be a timestamp, which creates inaccurate schedules for month and year billing periods. + * + * @param int $user_id The id of the user who the subscription belongs to + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.1.5 + * @deprecated 2.0 + */ + public static function maybe_reschedule_subscription_payment( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0' ); + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + // Don't reschedule for cancelled, suspended or expired subscriptions + if ( ! $subscription->has_status( 'expired', 'cancelled', 'on-hold' ) ) { + + // Reschedule the 'scheduled_subscription_payment' hook + if ( $subscription->can_date_be_updated( 'next_payment' ) ) { + $subscription->update_dates( array( 'next_payment' => $subscription->calculate_date( 'next_payment' ) ) ); + do_action( 'rescheduled_subscription_payment', $user_id, $subscription_key ); + } + } + } + + /** + * Fires when the trial period for a subscription has completed. + * + * @param int $subscription_id The ID of a 'shop_subscription' post + * @since 1.0 + * @deprecated 2.0 + */ + public static function subscription_trial_end( $subscription_id, $deprecated = null ) { + _deprecated_function( __METHOD__, '2.0' ); + } +} + +WC_Subscriptions_Manager::init(); diff --git a/includes/class-wc-subscriptions-order.php b/includes/class-wc-subscriptions-order.php new file mode 100644 index 0000000..74bf251 --- /dev/null +++ b/includes/class-wc-subscriptions-order.php @@ -0,0 +1,2039 @@ + array(), + 'trial_expiration' => array(), + 'expiration_date' => array(), + ); + + /** + * A flag to indicate whether subscription price strings should include the subscription length + */ + public static $recurring_only_price_strings = false; + + /** + * Bootstraps the class and hooks required actions & filters. + * + * @since 1.0 + */ + public static function init() { + + add_action( 'woocommerce_thankyou', __CLASS__ . '::subscription_thank_you' ); + + add_action( 'manage_shop_order_posts_custom_column', __CLASS__ . '::add_contains_subscription_hidden_field', 10, 1 ); + add_action( 'woocommerce_admin_order_data_after_order_details', __CLASS__ . '::contains_subscription_hidden_field', 10, 1 ); + + // Record initial payment against the subscription & set start date based on that payment + add_action( 'woocommerce_order_status_changed', __CLASS__ . '::maybe_record_subscription_payment', 9, 3 ); + + // Sometimes, even if the order total is $0, the order still needs payment + add_filter( 'woocommerce_order_needs_payment', __CLASS__ . '::order_needs_payment' , 10, 3 ); + + // Add subscription information to the order complete emails. + add_action( 'woocommerce_email_after_order_table', __CLASS__ . '::add_sub_info_email', 15, 3 ); + + // Add dropdown to admin orders screen to filter on order type + add_action( 'restrict_manage_posts', __CLASS__ . '::restrict_manage_subscriptions', 50 ); + + // Add filer to queries on admin orders screen to filter on order type + add_filter( 'request', __CLASS__ . '::orders_by_type_query' ); + + // Don't display migrated order item meta on the Edit Order screen + add_filter( 'woocommerce_hidden_order_itemmeta', __CLASS__ . '::hide_order_itemmeta' ); + + add_action( 'woocommerce_order_details_after_order_table', __CLASS__ . '::add_subscriptions_to_view_order_templates', 10, 1 ); + + add_action( 'woocommerce_subscription_details_after_subscription_table', __CLASS__ . '::get_related_orders_template', 10, 1 ); + + add_action( 'woocommerce_order_partially_refunded', __CLASS__ . '::maybe_cancel_subscription_on_partial_refund' ); + add_action( 'woocommerce_order_fully_refunded', __CLASS__ . '::maybe_cancel_subscription_on_full_refund' ); + + add_filter( 'woocommerce_order_needs_shipping_address', __CLASS__ . '::maybe_display_shipping_address', 10, 3 ); + } + + /* + * Helper functions for extracting the details of subscriptions in an order + */ + + /** + * Returns the total amount to be charged for non-subscription products at the outset of a subscription. + * + * This may return 0 if there no non-subscription products in the cart, or otherwise it will be the sum of the + * line totals for each non-subscription product. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @since 1.5.3 + */ + public static function get_non_subscription_total( $order ) { + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + $non_subscription_total = 0; + + foreach ( $order->get_items() as $order_item ) { + if ( ! self::is_item_subscription( $order, $order_item ) ) { + $non_subscription_total += $order_item['line_total']; + } + } + + return apply_filters( 'woocommerce_subscriptions_order_non_subscription_total', $non_subscription_total, $order ); + } + + /** + * Returns the total sign-up fee for all subscriptions in an order. + * + * Similar to WC_Subscription::get_sign_up_fee() except that it sums the sign-up fees for all subscriptions purchased in an order. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return float The initial sign-up fee charged when the subscription product in the order was first purchased, if any. + * @since 1.0 + */ + public static function get_sign_up_fee( $order, $product_id = '' ) { + + $sign_up_fee = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + if ( empty( $product_id ) ) { + + $sign_up_fee += $subscription->get_sign_up_fee(); + + } else { + + // We only want sign-up fees for certain product + $order_item = self::get_item_by_product_id( $order, $product_id ); + + foreach ( $subscription->get_items() as $line_item ) { + if ( $line_item['product_id'] == $product_id || $line_item['variation_id'] == $product_id ) { + $sign_up_fee += $subscription->get_items_sign_up_fee( $line_item ); + } + } + } + } + + return apply_filters( 'woocommerce_subscriptions_sign_up_fee', $sign_up_fee, $order, $product_id ); + } + + /** + * Gets the product ID for an order item in a way that is backwards compatible with WC 1.x. + * + * Version 2.0 of WooCommerce changed the ID of an order item from its product ID to a unique ID for that particular item. + * This function checks if the 'product_id' field exists on an order item before falling back to 'id'. + * + * @param array $order_item An order item in the structure returned by WC_Order::get_items() + * @since 1.2.5 + */ + public static function get_items_product_id( $order_item ) { + return ( isset( $order_item['product_id'] ) ) ? $order_item['product_id'] : $order_item['id']; + } + + /** + * Gets an item by product id from an order. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param int $product_id The product/post ID of a subscription product. + * @since 1.2.5 + */ + public static function get_item_by_product_id( $order, $product_id = '' ) { + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + foreach ( $order->get_items() as $item ) { + if ( ( self::get_items_product_id( $item ) == $product_id || empty( $product_id ) ) && self::is_item_subscription( $order, $item ) ) { + return $item; + } + } + + return array(); + } + + /** + * Gets an item by a subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key(). + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param int $product_id The product/post ID of a subscription product. + * @since 1.2.5 + */ + public static function get_item_by_subscription_key( $subscription_key ) { + + $item_id = self::get_item_id_by_subscription_key( $subscription_key ); + + $item = self::get_item_by_id( $item_id ); + + return $item; + } + + /** + * Gets the ID of a subscription item which belongs to a subscription key of the form created + * by @see WC_Subscriptions_Manager::get_subscription_key(). + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param int $product_id The product/post ID of a subscription product. + * @since 1.4 + */ + public static function get_item_id_by_subscription_key( $subscription_key ) { + global $wpdb; + + $order_and_product_ids = explode( '_', $subscription_key ); + + $item_id = $wpdb->get_var( $wpdb->prepare( + "SELECT `{$wpdb->prefix}woocommerce_order_items`.order_item_id FROM `{$wpdb->prefix}woocommerce_order_items` + INNER JOIN `{$wpdb->prefix}woocommerce_order_itemmeta` on `{$wpdb->prefix}woocommerce_order_items`.order_item_id = `{$wpdb->prefix}woocommerce_order_itemmeta`.order_item_id + AND `{$wpdb->prefix}woocommerce_order_itemmeta`.meta_key = '_product_id' + AND `{$wpdb->prefix}woocommerce_order_itemmeta`.meta_value = %d + WHERE `{$wpdb->prefix}woocommerce_order_items`.order_id = %d", + $order_and_product_ids[1], + $order_and_product_ids[0] + ) ); + + return $item_id; + } + + /** + * Gets an individual order item by ID without requiring the order ID associated with it. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param int $item_id The product/post ID of a subscription. Option - if no product id is provided, the first item's meta will be returned + * @return array $item An array containing the order_item_id, order_item_name, order_item_type, order_id and any item_meta. Array structure matches that returned by WC_Order::get_items() + * @since 1.2.5 + */ + public static function get_item_by_id( $order_item_id ) { + global $wpdb; + + $item = $wpdb->get_row( $wpdb->prepare( " + SELECT order_item_id, order_item_name, order_item_type, order_id + FROM {$wpdb->prefix}woocommerce_order_items + WHERE order_item_id = %d + ", $order_item_id ), ARRAY_A ); + + $order = new WC_Order( absint( $item['order_id'] ) ); + + $item['name'] = $item['order_item_name']; + $item['type'] = $item['order_item_type']; + $item['item_meta'] = $order->get_item_meta( $item['order_item_id'] ); + + // Put meta into item array + if ( is_array( $item['item_meta'] ) ) { + foreach ( $item['item_meta'] as $meta_name => $meta_value ) { + $key = substr( $meta_name, 0, 1 ) == '_' ? substr( $meta_name, 1 ) : $meta_name; + $item[ $key ] = maybe_unserialize( $meta_value[0] ); + } + } + + return $item; + } + + /** + * A unified API for accessing product specific meta on an order. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param string $meta_key The key as stored in the post meta table for the meta item. + * @param int $product_id The product/post ID of a subscription. Option - if no product id is provided, we will loop through the order and find the subscription + * @param mixed $default (optional) The default value to return if the meta key does not exist. Default 0. + * @since 1.2 + */ + public static function get_item_meta( $order, $meta_key, $product_id = '', $default = 0 ) { + + $meta_value = $default; + + if ( '' == $product_id ) { + $items = self::get_recurring_items( $order ); + foreach ( $items as $item ) { + $product_id = $item['product_id']; + break; + } + } + + $item = self::get_item_by_product_id( $order, $product_id ); + + if ( ! empty( $item ) && isset( $item['item_meta'][ $meta_key ] ) ) { + $meta_value = $item['item_meta'][ $meta_key ][0]; + } + + return apply_filters( 'woocommerce_subscriptions_item_meta', $meta_value, $meta_key, $order, $product_id ); + } + + /** + * Access an individual piece of item metadata (@see woocommerce_get_order_item_meta returns all metadata for an item) + * + * You may think it would make sense if this function was called "get_item_meta", and you would be correct, but a function + * with that name was created before the item meta data API of WC 2.0, so it needs to persist with it's own different + * set of parameters. + * + * @param int $meta_id The order item meta data ID of the item you want to get. + * @since 1.2.5 + */ + public static function get_item_meta_data( $meta_id ) { + global $wpdb; + + $item_meta = $wpdb->get_row( $wpdb->prepare( " + SELECT * + FROM {$wpdb->prefix}woocommerce_order_itemmeta + WHERE meta_id = %d + ", $meta_id ) ); + + return $item_meta; + } + + /** + * Gets the name of a subscription item by product ID from an order. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param int $product_id The product/post ID of a subscription. Option - if no product id is provided, it is expected that only one item exists and the last item's meta will be returned + * @since 1.2 + */ + public static function get_item_name( $order, $product_id = '' ) { + + $item = self::get_item_by_product_id( $order, $product_id ); + + if ( isset( $item['name'] ) ) { + return $item['name']; + } else { + return ''; + } + } + + /** + * A unified API for accessing subscription order meta, especially for sign-up fee related order meta. + * + * Because WooCommerce 2.1 deprecated WC_Order::$order_custom_fields, this function is also used to provide + * version independent meta data access to non-subscription meta data. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param string $meta_key The key as stored in the post meta table for the meta item. + * @param mixed $default (optional) The default value to return if the meta key does not exist. Default 0. + * @since 1.0 + */ + public static function get_meta( $order, $meta_key, $default = 0 ) { + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + $meta_key = preg_replace( '/^_/', '', $meta_key ); + + if ( isset( $order->$meta_key ) ) { // WC 2.1+ magic __isset() & __get() methods + $meta_value = $order->$meta_key; + } elseif ( is_array( $order->order_custom_fields ) && isset( $order->order_custom_fields[ '_' . $meta_key ][0] ) && $order->order_custom_fields[ '_' . $meta_key ][0] ) { // < WC 2.1+ + $meta_value = maybe_unserialize( $order->order_custom_fields[ '_' . $meta_key ][0] ); + } else { + $meta_value = get_post_meta( $order->id, '_' . $meta_key, true ); + + if ( empty( $meta_value ) ) { + $meta_value = $default; + } + } + + return $meta_value; + } + + /** + * Displays a few details about what happens to their subscription. Hooked + * to the thank you page. + * + * @since 1.0 + */ + public static function subscription_thank_you( $order_id ) { + + if ( wcs_order_contains_subscription( $order_id, 'any' ) ) { + + $subscription_count = count( wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'any' ) ) ); + + $thank_you_message = '

    ' . _n( 'Your subscription will be activated when payment clears.', 'Your subscriptions will be activated when payment clears.', $subscription_count, 'woocommerce-subscriptions' ) . '

    '; + + // translators: placeholders are opening and closing link tags + $thank_you_message .= '

    ' . sprintf( _n( 'View the status of your subscription in %syour account%s.', 'View the status of your subscriptions in %syour account%s.', $subscription_count, 'woocommerce-subscriptions' ), '', '' ) . '

    '; + echo wp_kses( apply_filters( 'woocommerce_subscriptions_thank_you_message', $thank_you_message, $order_id ), array( 'a' => array( 'href' => array(), 'title' => array() ), 'p' => array(), 'em' => array(), 'strong' => array() ) ); + } + + } + + /** + * Output a hidden element in the order status of the orders list table to provide information about whether + * the order displayed in that row contains a subscription or not. + * + * It would be more semantically correct to display a hidden input element than a span element with data, but + * that can result in "requested URL's length exceeds the capacity limit" errors when bulk editing orders. + * + * @param string $column The string of the current column. + * @since 1.1 + */ + public static function add_contains_subscription_hidden_field( $column ) { + global $post; + + if ( 'order_status' == $column ) { + $contains_subscription = wcs_order_contains_subscription( $post->ID, 'parent' ) ? 'true' : 'false'; + printf( '', esc_attr( $contains_subscription ) ); + } + } + + /** + * Output a hidden element on the Edit Order screen to provide information about whether the order displayed + * in that row contains a subscription or not. + * + * @param string $column The string of the current column. + * @since 1.1 + */ + public static function contains_subscription_hidden_field( $order_id ) { + + $has_subscription = wcs_order_contains_subscription( $order_id, 'parent' ) ? 'true' : 'false'; + + echo ''; + } + + /** + * Records the initial payment against a subscription. + * + * This function is called when an orders status is changed to completed or processing + * for those gateways which never call @see WC_Order::payment_complete(), like the core + * WooCommerce Cheque and Bank Transfer gateways. + * + * It will also set the start date on the subscription to the time the payment is completed. + * + * @param $order_id int|WC_Order + * @param $old_order_status + * @param $new_order_status + * @since 2.0 + */ + public static function maybe_record_subscription_payment( $order_id, $old_order_status, $new_order_status ) { + + if ( wcs_order_contains_subscription( $order_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order_id ); + $was_activated = false; + $order_completed = in_array( $new_order_status, array( apply_filters( 'woocommerce_payment_complete_order_status', 'processing', $order_id ), 'processing', 'completed' ) ) && in_array( $old_order_status, apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( 'pending', 'on-hold', 'failed' ) ) ); + + foreach ( $subscriptions as $subscription ) { + + // Do we need to activate a subscription? + if ( $order_completed && ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) && ! $subscription->has_status( 'active' ) ) { + + $new_start_date_offset = current_time( 'timestamp', true ) - $subscription->get_time( 'start' ); + + // if the payment has been processed more than an hour after the order was first created, let's update the dates on the subscription to account for that, because it may have even been processed days after it was first placed + if ( $new_start_date_offset > HOUR_IN_SECONDS ) { + + $dates = array( 'start' => current_time( 'mysql', true ) ); + + if ( WC_Subscriptions_Synchroniser::subscription_contains_synced_product( $subscription ) ) { + + $trial_end = $subscription->get_time( 'trial_end' ); + $next_payment = $subscription->get_time( 'next_payment' ); + + // if either there is a free trial date or a next payment date that falls before now, we need to recalculate all the sync'd dates + if ( ( $trial_end > 0 && $trial_end < strtotime( $dates['start'] ) ) || ( $next_payment > 0 && $next_payment < strtotime( $dates['start'] ) ) ) { + + foreach ( $subscription->get_items() as $item ) { + $product_id = wcs_get_canonical_product_id( $item ); + + if ( WC_Subscriptions_Synchroniser::is_product_synced( $product_id ) ) { + $dates['trial_end'] = WC_Subscriptions_Product::get_trial_expiration_date( $product_id, $dates['start'] ); + $dates['next_payment'] = WC_Subscriptions_Synchroniser::calculate_first_payment_date( $product_id, 'mysql', $dates['start'] ); + $dates['end'] = WC_Subscriptions_Product::get_expiration_date( $product_id, $dates['start'] ); + break; + } + } + } + } else { + // No sync'ing to mess about with, just add the offset to the existing dates + foreach ( array( 'trial_end', 'next_payment', 'end' ) as $date_type ) { + if ( 0 != $subscription->get_time( $date_type ) ) { + $dates[ $date_type ] = gmdate( 'Y-m-d H:i:s', $subscription->get_time( $date_type ) + $new_start_date_offset ); + } + } + } + + $subscription->update_dates( $dates ); + } + + $subscription->payment_complete(); + $was_activated = true; + + } elseif ( 'failed' == $new_order_status ) { + $subscription->payment_failed(); + } + } + + if ( $was_activated ) { + do_action( 'subscriptions_activated_for_order', $order_id ); + } + } + } + + /* Order Price Getters */ + + /** + * Checks if a given order item matches a line item from a subscription purchased in the order. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @param array $item | int An array representing an order item or a product ID of an item in an order (not an order item ID) + * @since 1.2 + */ + public static function is_item_subscription( $order, $order_item ) { + + if ( ! is_array( $order_item ) ) { + $order_item = self::get_item_by_product_id( $order, $order_item ); + } + + $order_items_product_id = wcs_get_canonical_product_id( $order_item ); + $item_is_subscription = false; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $order_items_product_id ) { + $item_is_subscription = true; + break 2; + } + } + } + + return $item_is_subscription; + } + + + /* Edit Order Page Content */ + + /** + * Returns all parent subscription orders for a user, specificed with $user_id + * + * @return array An array of order IDs. + * @since 1.4 + */ + public static function get_users_subscription_orders( $user_id = 0 ) { + global $wpdb; + + if ( 0 === $user_id ) { + $user_id = get_current_user_id(); + } + + // Get all the customers orders which are not subscription renewal orders + $order_ids = get_posts( array( + 'posts_per_page' => 1, + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => '_customer_user', + 'compare' => '=', + 'value' => $user_id, + 'type' => 'numeric', + ), + array( + 'key' => '_subscription_renewal', + 'compare' => 'NOT EXISTS', + ), + ), + ) ); + + foreach ( $order_ids as $index => $order_id ) { + if ( ! wcs_order_contains_subscription( $order_id, 'parent' ) ) { + unset( $order_ids[ $index ] ); + } + } + + // Normalise array keys + $order_ids = array_values( $order_ids ); + + return apply_filters( 'users_subscription_orders', $order_ids, $user_id ); + } + + /** + * Check whether an order needs payment even if the order total is $0 (because it has a recurring total and + * automatic payments are not switched off) + * + * @param bool $needs_payment The existing flag for whether the cart needs payment or not. + * @param WC_Order $order A WooCommerce WC_Order object. + * @return bool + */ + public static function order_needs_payment( $needs_payment, $order, $valid_order_statuses ) { + + if ( wcs_order_contains_subscription( $order ) && in_array( $order->status, $valid_order_statuses ) && 0 == $order->get_total() && false === $needs_payment && self::get_recurring_total( $order ) > 0 && 'yes' !== get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) ) { + $needs_payment = true; + } + + return $needs_payment; + } + + /** + * Adds the subscription information to our order emails. + * + * @since 1.5 + */ + public static function add_sub_info_email( $order, $is_admin_email, $plaintext = false ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'any' ) ); + + if ( ! empty( $subscriptions ) ) { + + $template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + $template = ( $plaintext ) ? 'emails/plain/subscription-info.php' : 'emails/subscription-info.php'; + + wc_get_template( + $template, + array( + 'order' => $order, + 'subscriptions' => $subscriptions, + 'is_admin_email' => $is_admin_email, + ), + '', + $template_base + ); + } + } + + /** + * Wrapper around @see WC_Order::get_order_currency() for versions of WooCommerce prior to 2.1. + * + * @since version 1.4.9 + */ + public static function get_order_currency( $order ) { + + if ( method_exists( $order, 'get_order_currency' ) ) { + $order_currency = $order->get_order_currency(); + } else { + $order_currency = get_woocommerce_currency(); + } + + return $order_currency; + } + + /** + * Add admin dropdown for order types to Woocommerce -> Orders screen + * + * @since version 1.5 + */ + public static function restrict_manage_subscriptions() { + global $typenow, $wp_query; + + if ( 'shop_order' != $typenow ) { + return; + }?> + + Orders screen + * + * Including or excluding posts with a '_subscription_renewal' meta value includes or excludes + * renewal orders, as required. + * + * @since 1.5 + */ + public static function orders_by_type_query( $vars ) { + global $typenow, $wp_query; + + if ( 'shop_order' == $typenow && isset( $_GET['shop_order_subtype'] ) ) { + + if ( 'Original' == $_GET['shop_order_subtype'] ) { + $key = 'post__not_in'; + } elseif ( 'Renewal' == $_GET['shop_order_subtype'] ) { + $key = 'post__in'; + } + + if ( ! empty( $key ) ) { + $vars[ $key ] = get_posts( array( + 'posts_per_page' => -1, + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => 'EXISTS', + ), + ), + ) ); + } + } + + return $vars; + } + + /** + * Add related subscriptions below order details tables. + * + * @since 2.0 + */ + public static function add_subscriptions_to_view_order_templates( $order_id ) { + + $template = 'myaccount/related-subscriptions.php'; + $subscriptions = wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'any' ) ); + + if ( ! empty( $subscriptions ) ) { + wc_get_template( $template, array( 'order_id' => $order_id, 'subscriptions' => $subscriptions ), '', plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + } + } + + /** + * Loads the related orders table on the view subscription page + * + * @since 2.0 + */ + public static function get_related_orders_template( $subscription ) { + + $subscription_orders = $subscription->get_related_orders(); + + if ( 0 !== count( $subscription_orders ) ) { + wc_get_template( 'myaccount/related-orders.php', array( 'subscription_orders' => $subscription_orders, 'subscription' => $subscription ), '', plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + } + } + + /** + * Allow subscription order items to be edited in WC 2.2. until Subscriptions 2.0 introduces + * its own WC_Subscription object. + * + * @since 1.5.10 + */ + public static function is_order_editable( $is_editable, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::is_editable()' ); + return $is_editable; + } + + /** + * Get a subscription that has an item with the same product/variation ID as an order item, if any. + * + * In Subscriptions v1.n, a subscription's meta data, like recurring total, billing period etc. were stored + * against the line item on the original order for that subscription. + * + * In v2.0, this data was moved to a distinct subscription object which had its own line items for those amounts. + * This function bridges the two data structures to support deprecated functions used to retreive a subscription's + * meta data from the original order rather than the subscription itself. + * + * @param WC_Order $order A WC_Order object + * @param int $product_id The product/post ID of a subscription + * @return null|object A subscription from the order, either with an item to the product ID (if any) or just the first subscription purchase in the order. + * @since 2.0 + */ + private static function get_matching_subscription( $order, $product_id = '' ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ); + $matching_subscription = null; + + if ( ! empty( $product_id ) ) { + foreach ( $subscriptions as $subscription ) { + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $matching_subscription = $subscription; + break 2; + } + } + } + } + + if ( null === $matching_subscription && ! empty( $subscriptions ) ) { + $matching_subscription = array_pop( $subscriptions ); + } + + return $matching_subscription; + } + + /** + * Get the subscription item that has the same product/variation ID as an order item, if any. + * + * In Subscriptions v1.n, a subscription's meta data, like recurring total, billing period etc. were stored + * against the line item on the original order for that subscription. + * + * In v2.0, this data was moved to a distinct subscription object which had its own line items for those amounts. + * This function bridges the two data structures to support deprecated functions used to retreive a subscription's + * meta data from the original order rather than the subscription itself. + * + * @param WC_Order $order A WC_Order object + * @param int $product_id The product/post ID of a subscription + * @return array The line item for this product on the subscription object + * @since 2.0 + */ + private static function get_matching_subscription_item( $order, $product_id = '' ) { + + $matching_item = array(); + $subscription = self::get_matching_subscription( $order, $product_id ); + + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $matching_item = $line_item; + break; + } + } + + return $matching_item; + } + + /** + * Don't display migrated subscription meta data on the Edit Order screen + * + * @since 1.4 + */ + public static function hide_order_itemmeta( $hidden_meta_keys ) { + + if ( ! defined( 'WCS_DEBUG' ) || true !== WCS_DEBUG ) { + + $hidden_meta_keys[] = '_has_trial'; + + $old_recurring_meta_keys = array( + '_line_total', + '_line_tax', + '_line_subtotal', + '_line_subtotal_tax', + ); + + foreach ( $old_recurring_meta_keys as $index => $meta_key ) { + $old_recurring_meta_keys[ $index ] = sprintf( '_wcs_migrated_recurring%s', $meta_key ); + } + + $hidden_meta_keys = array_merge( $hidden_meta_keys, $old_recurring_meta_keys ); + + $old_subscription_meta_keys = array( + '_period', + '_interval', + '_trial_length', + '_trial_period', + '_length', + '_sign_up_fee', + '_failed_payments', + '_recurring_amount', + '_start_date', + '_trial_expiry_date', + '_expiry_date', + '_end_date', + '_status', + '_completed_payments', + '_suspension_count', + ); + + foreach ( $old_subscription_meta_keys as $index => $meta_key ) { + $old_subscription_meta_keys[ $index ] = sprintf( '_wcs_migrated_subscription%s', $meta_key ); + } + + $hidden_meta_keys = array_merge( $hidden_meta_keys, $old_subscription_meta_keys ); + } + + return $hidden_meta_keys; + } + + /** + * If the subscription is pending cancellation and a latest order is refunded, cancel the subscription. + * + * @param $order_id + * + * @since 2.0 + */ + public static function maybe_cancel_subscription_on_full_refund( $order ) { + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + if ( wcs_order_contains_subscription( $order, array( 'parent', 'renewal' ) ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order->id, array( 'order_type' => array( 'parent', 'renewal' ) ) ); + + foreach ( $subscriptions as $subscription ) { + $latest_order = $subscription->get_last_order(); + + if ( $order->id == $latest_order && $subscription->has_status( 'pending-cancel' ) && $subscription->can_be_updated_to( 'cancelled' ) ) { + // translators: $1: opening link tag, $2: order number, $3: closing link tag + $subscription->update_status( 'cancelled', wp_kses( sprintf( __( 'Subscription cancelled for refunded order %1$s#%2$s%3$s.', 'woocommerce-subscriptions' ), sprintf( '', esc_url( wcs_get_edit_post_link( $order->id ) ) ), $order->get_order_number(), '' ), array( 'a' => array( 'href' => true ) ) ) ); + } + } + } + } + + /** + * Handles partial refunds on orders in WC versions pre 2.5 which would be considered full refunds in WC 2.5. + * + * @param $order_id + * + * @since 2.0 + */ + public static function maybe_cancel_subscription_on_partial_refund( $order_id ) { + + if ( WC_Subscriptions::is_woocommerce_pre( '2.5' ) && wcs_order_contains_subscription( $order_id, array( 'parent', 'renewal' ) ) ) { + + $order = wc_get_order( $order_id ); + $remaining_order_total = wc_format_decimal( $order->get_total() - $order->get_total_refunded() ); + $remaining_order_items = absint( $order->get_item_count() - $order->get_item_count_refunded() ); + $order_has_free_item = false; + + foreach ( $order->get_items() as $item ) { + if ( ! $item['line_total'] ) { + $order_has_free_item = true; + break; + } + } + + if ( ! ( $remaining_order_total > 0 || ( $order_has_free_item && $remaining_order_items > 0 ) ) ) { + self::maybe_cancel_subscription_on_full_refund( $order ); + } + } + } + + /** + * If the order doesn't contain shipping methods because it contains synced or trial products but the related subscription(s) does have a shipping method. + * This function will ensure the shipping address is still displayed in order emails and on the order received and view order pages. + * + * @param bool $needs_shipping + * @param array $hidden_shipping_methods shipping method IDs which should hide shipping addresses (defaulted to array( 'local_pickup' )) + * @param WC_Order $order + * + * @return bool $needs_shipping whether an order needs to display the shipping address + * + * @since 2.0.14 + */ + public static function maybe_display_shipping_address( $needs_shipping, $hidden_shipping_methods, $order ) { + $order_shipping_methods = $order->get_shipping_methods(); + + if ( ! $needs_shipping && wcs_order_contains_subscription( $order ) && empty( $order_shipping_methods ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order ); + + foreach ( $subscriptions as $subscription ) { + foreach ( $subscription->get_shipping_methods() as $shipping_method ) { + + if ( ! in_array( $shipping_method['method_id'], $hidden_shipping_methods ) ) { + $needs_shipping = true; + break 2; + } + } + } + } + + return $needs_shipping; + } + + /* Deprecated Functions */ + + /** + * Returned the recurring amount for a subscription in an order. + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function get_price_per_period( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ . '::get_recurring_total( $order, $product_id )' ); + return self::get_recurring_total( $order, $product_id ); + } + + /** + * Creates a new order for renewing a subscription product based on the details of a previous order. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the a new order should be created. + * @param string $product_id The ID of the subscription product in the order which needs to be added to the new order. + * @param string $new_order_role A flag to indicate whether the new order should become the master order for the subscription. Accepts either 'parent' or 'child'. Defaults to 'parent' - replace the existing order. + * @deprecated 1.2 + * @since 1.0 + */ + public static function generate_renewal_order( $original_order, $product_id, $new_order_role = 'parent' ) { + _deprecated_function( __METHOD__, '1.2', 'WC_Subscriptions_Renewal_Order::generate_renewal_order( $original_order, $product_id, array( "new_order_role" => $new_order_role ) )' ); + return WC_Subscriptions_Renewal_Order::generate_renewal_order( $original_order, $product_id, array( 'new_order_role' => $new_order_role ) ); + } + + /** + * Hooks to the renewal order created action to determine if the order should be emailed to the customer. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @deprecated 1.2 + * @since 1.0 + */ + public static function maybe_send_customer_renewal_order_email( $order ) { + _deprecated_function( __METHOD__, '1.2', 'WC_Subscriptions_Renewal_Order::maybe_send_customer_renewal_order_email( $order )' ); + WC_Subscriptions_Renewal_Order::maybe_send_customer_renewal_order_email( $order ); + } + + /** + * Processing Order + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @deprecated 1.2 + * @since 1.0 + */ + public static function send_customer_renewal_order_email( $order ) { + _deprecated_function( __METHOD__, '1.2', 'WC_Subscriptions_Renewal_Order::send_customer_renewal_order_email( $order )' ); + WC_Subscriptions_Renewal_Order::send_customer_renewal_order_email( $order ); + } + + /** + * Check if a given order is a subscription renewal order + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @deprecated 1.2 + * @since 1.0 + */ + public static function is_renewal( $order ) { + _deprecated_function( __METHOD__, '1.2', 'wcs_order_contains_renewal( $order )' ); + return wcs_order_contains_renewal( $order ); + } + + /** + * Once payment is completed on an order, record the payment against the subscription automatically so that + * payment gateway extension developers don't have to do this. + * + * @param int $order_id The id of the order to record payment against + * @deprecated 1.2 + * @since 1.1.2 + */ + public static function record_order_payment( $order_id ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ . '::maybe_record_order_payment( $order_id )' ); + return self::maybe_record_order_payment( $order_id ); + } + + /** + * Checks an order item to see if it is a subscription. The item needs to exist and have been a subscription + * product at the time of purchase for the function to return true. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The ID of a WC_Product object purchased in the order. + * @return bool True if the order contains a subscription, otherwise false. + * @deprecated 1.2.4 + */ + public static function is_item_a_subscription( $order, $product_id ) { + _deprecated_function( __METHOD__, '1.2.4', __CLASS__ . '::is_item_subscription( $order, $product_id )' ); + return self::is_item_subscription( $order, $product_id ); + } + + /** + * Deprecated due to change of order item ID/API in WC 2.0. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the meta should be sought. + * @param int $item_id The product/post ID of a subscription. Option - if no product id is provided, the first item's meta will be returned + * @since 1.2 + * @deprecated 1.2.5 + */ + public static function get_item( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '1.2.5', __CLASS__ . '::get_item_by_product_id( $order, $product_id )' ); + return self::get_item_by_product_id( $order, $product_id ); + } + + /** + * Deprecated due to different totals calculation method. + * + * Determined the proportion of the order total that a recurring amount accounts for and + * returns that proportion. + * + * If there is only one subscription in the order and no sign up fee for the subscription, + * this function will return 1 (i.e. 100%). + * + * Shipping only applies to recurring amounts so is deducted from both the order total and + * recurring amount so it does not distort the proportion. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @return float The proportion of the order total which the recurring amount accounts for + * @since 1.2 + * @deprecated 1.4 + */ + public static function get_recurring_total_proportion( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '1.4' ); + + $order_shipping_total = self::get_recurring_shipping_total( $order ) + self::get_recurring_shipping_tax_total( $order ); + $order_total_sans_shipping = $order->get_total() - $order_shipping_total; + $recurring_total_sans_shipping = self::get_recurring_total( $order, $product_id ) - $order_shipping_total; + + return $recurring_total_sans_shipping / $order_total_sans_shipping; + } + + /** + * Checks an order to see if it contains a subscription. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @return bool True if the order contains a subscription, otherwise false. + * @version 1.2 + * @since 1.0 + */ + public static function order_contains_subscription( $order ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_order_contains_subscription( $order )' ); + return wcs_order_contains_subscription( $order ); + } + + /** + * This function once made sure the recurring payment method was set correctly on an order when a customer placed an order + * with one payment method (like PayPal), and then returned and completed payment using a different payment method. + * + * With the advent of a separate subscription object in 2.0, this became unnecessary. + * + * @since 1.4 + */ + public static function set_recurring_payment_method( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Checks if an order contains an in active subscription and if it does, denies download acces + * to files purchased on the order. + * + * @return bool False if the order contains a subscription that has expired or is cancelled/on-hold, otherwise, the original value of $download_permitted + * @since 1.3 + */ + public static function is_download_permitted( $download_permitted, $order ) { + _deprecated_function( __METHOD__, '2.0' ); + return $download_permitted; + } + + /** + * Add subscription related order item meta when a subscription product is added as an item to an order via Ajax. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * @param item_id int An order_item_id as returned by the insert statement of @see woocommerce_add_order_item() + * @since 1.2.5 + * @version 1.4 + * @return void + */ + public static function prefill_order_item_meta( $item, $item_id ) { + _deprecated_function( __METHOD__, '2.0' ); + return $item; + } + + /** + * Calculate recurring line taxes when a store manager clicks the "Calc Line Tax" button on the "Edit Order" page. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * Based on the @see woocommerce_calc_line_taxes() function. + * @since 1.2.4 + * @return void + */ + public static function calculate_recurring_line_taxes() { + _deprecated_function( __METHOD__, '2.0' ); + die(); + } + + /** + * Removes a line tax item from an order by ID. Hooked to + * an Ajax call from the "Edit Order" page and mirrors the + * @see woocommerce_remove_line_tax() function. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * @return void + */ + public static function remove_line_tax() { + _deprecated_function( __METHOD__, '2.0' ); + die(); + } + + /** + * Adds a line tax item from an order by ID. Hooked to + * an Ajax call from the "Edit Order" page and mirrors the + * @see woocommerce_add_line_tax() function. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * @return void + */ + public static function add_line_tax() { + _deprecated_function( __METHOD__, '2.0' ); + die(); + } + + /** + * Display recurring order totals on the "Edit Order" page. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * @param int $post_id The post ID of the shop_order post object. + * @since 1.2.4 + * @return void + */ + public static function recurring_order_totals_meta_box_section( $post_id ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * When an order is added or updated from the admin interface, check if a subscription product + * has been manually added to the order or the details of the subscription have been modified, + * and create/update the subscription as required. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * @param int $post_id The ID of the post which is the WC_Order object. + * @param Object $post The post object of the order. + * @since 1.1 + */ + public static function pre_process_shop_order_meta( $post_id, $post ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Worked around a bug in WooCommerce which ignores order item meta values of 0. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen and those values + * are stored against a 'shop_subscription' post, not the 'shop_order' used to purchase the subscription. + * + * @param int $post_id The ID of the post which is the WC_Order object. + * @param Object $post The post object of the order. + * @since 1.2.4 + */ + public static function process_shop_order_item_meta( $post_id, $post ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Checks if a subscription requires manual payment because the payment gateway used to purchase the subscription + * did not support automatic payments at the time of the subscription sign up. Or because we're on a staging site. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @return bool True if the subscription exists and requires manual payments, false if the subscription uses automatic payments (defaults to false for backward compatibility). + * @since 1.2 + */ + public static function requires_manual_renewal( $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::is_manual()' ); + + $requires_manual_renewal = true; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + if ( ! $subscription->is_manual() ) { + $requires_manual_renewal = false; + break; + } + } + + return $requires_manual_renewal; + } + + /** + * Returns the total amount to be charged at the outset of the Subscription. + * + * This may return 0 if there is a free trial period and no sign up fee, otherwise it will be the sum of the sign up + * fee and price per period. This function should be used by payment gateways for the initial payment. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @return float The total initial amount charged when the subscription product in the order was first purchased, if any. + * @since 1.1 + */ + public static function get_total_initial_payment( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Order::get_total()' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + return apply_filters( 'woocommerce_subscriptions_total_initial_payment', $order->get_total(), $order, $product_id ); + } + + /** + * Returns the recurring amount for an item + * + * @param WC_Order $order A WC_Order object + * @param int $product_id The product/post ID of a subscription + * @return float The total amount to be charged for each billing period, if any, not including failed payments. + * @since 1.2 + */ + public static function get_item_recurring_amount( $order, $product_id ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the item on the subscription object rather than the value on the original order. A line item can be deleted from a subscription since Subscriptions v2.0, so even if it exists on an order, it may not exist as a subscription. That means for accurate results, you must use the value on the subscription object' ); + + $subscription_item = self::get_matching_subscription_item( $order, $product_id ); + + if ( isset( $subscription_item['line_total'] ) ) { + $recurring_amount = $subscription_item['line_total'] / $subscription_item['qty']; + } else { + $recurring_amount = 0; + } + + return $recurring_amount; + } + + /** + * Returns the proportion of cart discount that is recurring for the product specified with $product_id + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_discount_cart( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different discounts, so use the subscription object' ); + + $recurring_discount_cart = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the total discount for all recurring items + if ( empty( $product_id ) ) { + $recurring_discount_cart += $subscription->get_total_discount(); + } else { + // We want the discount for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $recurring_discount_cart += $subscription->get_total_discount(); + break; + } + } + } + } + + return $recurring_discount_cart; + } + + /** + * Returns the proportion of cart discount tax that is recurring for the product specified with $product_id + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_discount_cart_tax( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different discounts, so use the subscription object' ); + + $recurring_discount_cart_tax = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the total discount for all recurring items + if ( empty( $product_id ) ) { + $recurring_discount_cart_tax += $subscription->get_total_discount(); + } else { + // We want the discount for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $recurring_discount_cart_tax += $subscription->get_total_discount(); + break; + } + } + } + } + + return $recurring_discount_cart_tax; + } + + /** + * Returns the proportion of total discount that is recurring for the product specified with $product_id + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_discount_total( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different discounts, so use the subscription object' ); + + $ex_tax = ( $order->tax_display_cart === 'excl' && $order->display_totals_ex_tax ) ? true : false; + + $recurring_discount_cart = (double) self::get_recurring_discount_cart( $order ); + $recurring_discount_cart_tax = (double) self::get_recurring_discount_cart_tax( $order ); + $recurring_discount_total = 0; + + if ( ! $order->order_version || version_compare( $order->order_version, '2.3.7', '<' ) ) { + // Backwards compatible total calculation - totals were not stored consistently in old versions. + if ( $ex_tax ) { + if ( $order->prices_include_tax ) { + $recurring_discount_total = $recurring_discount_cart - $recurring_discount_cart_tax; + } else { + $recurring_discount_total = $recurring_discount_cart; + } + } else { + if ( $order->prices_include_tax ) { + $recurring_discount_total = $recurring_discount_cart; + } else { + $recurring_discount_total = $recurring_discount_cart + $recurring_discount_cart_tax; + } + } + // New logic - totals are always stored exclusive of tax, tax total is stored in cart_discount_tax + } else { + if ( $ex_tax ) { + $recurring_discount_total = $recurring_discount_cart; + } else { + $recurring_discount_total = $recurring_discount_cart + $recurring_discount_cart_tax; + } + } + + return $recurring_discount_total; + } + + /** + * Returns the amount of shipping tax that is recurring. As shipping only applies + * to recurring payments, and only 1 subscription can be purchased at a time, + * this is equal to @see WC_Order::get_total_tax() + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_shipping_tax_total( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different amounts, so use the subscription object' ); + + $recurring_shipping_tax_total = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the total for all recurring items + if ( empty( $product_id ) ) { + $recurring_shipping_tax_total += $subscription->get_shipping_tax(); + } else { + // We want the amount for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $recurring_shipping_tax_total += $subscription->get_shipping_tax(); + break; + } + } + } + } + + return $recurring_shipping_tax_total; + } + + /** + * Returns the recurring shipping price . As shipping only applies to recurring + * payments, and only 1 subscription can be purchased at a time, this is + * equal to @see WC_Order::get_total_shipping() + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_shipping_total( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different amounts, so use the subscription object' ); + + $recurring_shipping_total = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the total for all recurring items + if ( empty( $product_id ) ) { + $recurring_shipping_total += $subscription->get_total_shipping(); + } else { + // We want the amount for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $recurring_shipping_total += $subscription->get_total_shipping(); + break; + } + } + } + } + + return $recurring_shipping_total; + } + + /** + * Return an array of shipping costs within this order. + * + * @return array + */ + public static function get_recurring_shipping_methods( $order ) { + _deprecated_function( __METHOD__, '2.0', 'the shipping for each individual subscription object rather than the original order. Shipping is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different shipping methods, so use the subscription object' ); + + $recurring_shipping_methods = array(); + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + $recurring_shipping_methods = array_merge( $recurring_shipping_methods, $subscription->get_shipping_methods() ); + } + + return $recurring_shipping_methods; + } + + /** + * Returns an array of taxes on an order with their recurring totals. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_taxes( $order ) { + _deprecated_function( __METHOD__, '2.0', 'the taxes for the subscription object rather than the original order. Taxes are stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different taxes, so use the subscription object' ); + + $recurring_taxes = array(); + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + $recurring_taxes = array_merge( $recurring_taxes, $subscription->get_taxes() ); + } + + return $recurring_taxes; + } + + /** + * Returns the proportion of total tax on an order that is recurring for the product specified with $product_id + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_total_tax( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different amounts, so use the subscription object' ); + + $recurring_total_tax = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the total for all recurring items + if ( empty( $product_id ) ) { + $recurring_total_tax += $subscription->get_total_tax(); + } else { + // We want the discount for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $recurring_total_tax += $subscription->get_total_tax(); + break; + } + } + } + } + + return $recurring_total_tax; + } + + /** + * Returns the proportion of total before tax on an order that is recurring for the product specified with $product_id + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_total_ex_tax( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the value for the subscription object rather than the value on the original order. The value is stored against the subscription since Subscriptions v2.0 as an order can be used to create multiple different subscriptions with different amounts, so use the subscription object' ); + return self::get_recurring_total( $order, $product_id ) - self::get_recurring_total_tax( $order, $product_id ); + } + + /** + * Returns the price per period for a subscription in an order. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @since 1.2 + */ + public static function get_recurring_total( $order ) { + $recurring_total = 0; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the total for all recurring items + if ( empty( $product_id ) ) { + $recurring_total += $subscription->get_total(); + } else { + // We want the discount for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $recurring_total += $subscription->get_total(); + break; + } + } + } + } + + return $recurring_total; + } + + /** + * Creates a string representation of the subscription period/term for each item in the cart + * + * @param WC_Order $order A WC_Order object. + * @param mixed $deprecated Never used. + * @param mixed $deprecated Never used. + * @since 1.0 + */ + public static function get_order_subscription_string( $order, $deprecated_price = '', $deprecated_sign_up_fee = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_formatted_order_total()' ); + + $initial_amount = wc_price( self::get_total_initial_payment( $order ) ); + + $subscription_string = self::get_formatted_order_total( $initial_amount, $order ); + + return $subscription_string; + } + + /** + * Returns an array of items in an order which are recurring along with their recurring totals. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + */ + public static function get_recurring_items( $order ) { + _deprecated_function( __METHOD__, '2.0', 'the items on each individual subscription object (i.e. "shop_subscription")' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + $items = array(); + + foreach ( $order->get_items() as $item_id => $item_details ) { + + if ( ! self::is_item_subscription( $order, $item_details ) ) { + continue; + } + + $items[ $item_id ] = $item_details; + $order_items_product_id = wcs_get_canonical_product_id( $item_details ); + $matching_subscription = self::get_matching_subscription( $order, $order_items_product_id ); + + // Set the line totals to be the recurring amounts, not the initial order's amount + if ( null !== $matching_subscription ) { + foreach ( $matching_subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $order_items_product_id ) { + $items[ $item_id ]['line_subtotal'] = $line_item['line_subtotal']; + $items[ $item_id ]['line_subtotal_tax'] = $line_item['line_subtotal_tax']; + $items[ $item_id ]['line_total'] = $line_item['line_total']; + $items[ $item_id ]['line_tax'] = $line_item['line_tax']; + break; + } + } + } + } + + return $items; + } + + /** + * Returns the period (e.g. month) for a each subscription product in an order. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return string A string representation of the period for the subscription, i.e. day, week, month or year. + * @since 1.0 + */ + public static function get_subscription_period( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the billing period for each individual subscription object. Since Subscriptions v2.0, an order can be used to create multiple different subscriptions with different billing schedules, so use the subscription object' ); + + $billing_period = ''; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the billing period discount for all recurring items + if ( empty( $product_id ) ) { + $billing_period = $subscription->billing_period; + break; + } else { + // We want the billing period for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $billing_period = $subscription->billing_period; + break 2; + } + } + } + } + + return $billing_period; + } + + /** + * Returns the billing interval for a each subscription product in an order. + * + * For example, this would return 3 for a subscription charged every 3 months or 1 for a subscription charged every month. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return int The billing interval for a each subscription product in an order. + * @since 1.0 + */ + public static function get_subscription_interval( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the billing interval for each individual subscription object. Since Subscriptions v2.0, an order can be used to create multiple different subscriptions with different billing schedules, so use the subscription object' ); + + $billing_interval = ''; + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + + // Find the billing interval for all recurring items + if ( empty( $product_id ) ) { + $billing_interval = $subscription->billing_interval; + break; + } else { + // We want the billing interval for a specific item (so we need to find if this subscription contains that item) + foreach ( $subscription->get_items() as $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == $product_id ) { + $billing_interval = $subscription->billing_interval; + break 2; + } + } + } + } + + return $billing_interval; + } + + /** + * Returns the length for a subscription in an order. + * + * There must be only one subscription in an order for this to be accurate. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return int The number of periods for which the subscription will recur. For example, a $5/month subscription for one year would return 12. A $10 every 3 month subscription for one year would also return 12. + * @since 1.0 + */ + public static function get_subscription_length( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the end date each individual subscription object. Since Subscriptions v2.0, an order can be used to create multiple different subscriptions with different billing schedules. The length of a subscription is also no longer stored against the subscription and instead, it is used simply to calculate the end date for the subscription when it is purchased. Therefore, you must use the end date of a subscription object' ); + return null; + } + + /** + * Returns the length for a subscription product's trial period as set when added to an order. + * + * The trial period is the same as the subscription period, as derived from @see self::get_subscription_period(). + * + * For now, there must be only one subscription in an order for this to be accurate. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return int The number of periods the trial period lasts for. For no trial, this will return 0, for a 3 period trial, it will return 3. + * @since 1.1 + */ + public static function get_subscription_trial_length( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the first payment date for each individual subscription object. Since Subscriptions v2.0, an order can be used to create multiple different subscriptions with different billing schedules. The trial length of a subscription is also no longer stored against the subscription and instead, it is used simply to calculate the first payment date for the subscription when it is purchased. Therefore, you must use the first payment date of a subscription object' ); + return null; + } + + /** + * Returns the period (e.g. month) for a subscription product's trial as set when added to an order. + * + * As of 1.2.x, a subscriptions trial period may be different than the recurring period + * + * For now, there must be only one subscription in an order for this to be accurate. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id (optional) The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return string A string representation of the period for the subscription, i.e. day, week, month or year. + * @since 1.2 + */ + public static function get_subscription_trial_period( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'the billing period for each individual subscription object. Since Subscriptions v2.0, an order can be used to create multiple different subscriptions with different billing schedules. The trial period of a subscription is also no longer stored against the subscription and instead, it is used simply to calculate the first payment date for the subscription when it is purchased. Therefore, you must use the billing period of a subscription object' ); + return self::get_subscription_period( $order, $product_id ); + } + + /** + * Takes a subscription product's ID and returns the timestamp on which the next payment is due. + * + * A convenience wrapper for @see WC_Subscriptions_Manager::get_next_payment_date() to get the + * next payment date for a subscription when all you have is the order and product. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The product/post ID of the subscription + * @param mixed $deprecated Never used. + * @return int If no more payments are due, returns 0, otherwise returns a timestamp of the date the next payment is due. + * @version 1.2 + * @since 1.0 + */ + public static function get_next_payment_timestamp( $order, $product_id, $deprecated = null ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_time( "next_payment" )' ); + + $next_payment_timestamp = 0; + + if ( $subscription = self::get_matching_subscription( $order, $product_id ) ) { + $next_payment_timestamp = $subscription->get_time( 'next_payment' ); + } + + return $next_payment_timestamp; + } + + /** + * Takes a subscription product's ID and the order it was purchased in and returns the date on + * which the next payment is due. + * + * A convenience wrapper for @see WC_Subscriptions_Manager::get_next_payment_date() to get the next + * payment date for a subscription when all you have is the order and product. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The product/post ID of the subscription + * @param mixed $deprecated Never used. + * @return mixed If no more payments are due, returns 0, otherwise it returns the MySQL formatted date/time string for the next payment date. + * @version 1.2 + * @since 1.0 + */ + public static function get_next_payment_date( $order, $product_id, $deprecated = null ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "next_payment" )' ); + + $next_payment_date = 0; + + if ( $subscription = self::get_matching_subscription( $order, $product_id ) ) { + $next_payment_date = $subscription->get_date( 'next_payment' ); + } + + return $next_payment_date; + } + + /** + * Takes a subscription product's ID and the order it was purchased in and returns the date on + * which the last payment was made. + * + * A convenience wrapper for @see WC_Subscriptions_Manager::get_last_payment_date() to get the next + * payment date for a subscription when all you have is the order and product. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The product/post ID of the subscription + * @param mixed $deprecated Never used. + * @return mixed If no more payments are due, returns 0, otherwise it returns the MySQL formatted date/time string for the next payment date. + * @version 1.2.1 + * @since 1.0 + */ + public static function get_last_payment_date( $order, $product_id ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_date( "last_payment" )' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + if ( $subscription = self::get_matching_subscription( $order, $product_id ) ) { + $last_payment_date = $subscription->get_date( 'last_payment' ); + } elseif ( isset( $order->paid_date ) ) { + $last_payment_date = get_gmt_from_date( $order->paid_date ); + } else { + $last_payment_date = $order->post->post_date; + } + + return $last_payment_date; + } + + /** + * Takes a subscription product's ID and calculates the date on which the next payment is due. + * + * Calculation is based on $from_date if specified, otherwise it will fall back to the last + * completed payment, the subscription's start time, or the current date/time, in that order. + * + * The next payment date will occur after any free trial period and up to any expiration date. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The product/post ID of the subscription + * @param string $type (optional) The format for the Either 'mysql' or 'timestamp'. + * @param mixed $from_date A MySQL formatted date/time string from which to calculate the next payment date, or empty (default), which will use the last payment on the subscription, or today's date/time if no previous payments have been made. + * @return mixed If there is no future payment set, returns 0, otherwise it will return a date of the next payment in the form specified by $type + * @since 1.0 + */ + public static function calculate_next_payment_date( $order, $product_id, $type = 'mysql', $from_date = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::calculate_date( "next_payment" )' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + $next_payment = 0; + + if ( $subscription = self::get_matching_subscription( $order, $product_id ) ) { + $next_payment = $subscription->calculate_date( 'next_payment' ); + } + + $next_payment = ( 'mysql' == $type && 0 != $next_payment ) ? $next_payment : strtotime( $next_payment ); + return apply_filters( 'woocommerce_subscriptions_calculated_next_payment_date', $next_payment, $order, $product_id, $type, $from_date, $from_date ); + } + + /** + * Returns the number of failed payments for a given subscription. + * + * @param WC_Order $order The WC_Order object of the order for which you want to determine the number of failed payments. + * @param product_id int The ID of the subscription product. + * @return string The key representing the given subscription. + * @since 1.0 + */ + public static function get_failed_payment_count( $order, $product_id ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_failed_payment_count()' ); + + $failed_payment_count = 0; + + if ( $subscription = self::get_matching_subscription( $order, $product_id ) ) { + $failed_payment_count = $subscription->get_failed_payment_count(); + } + + return $failed_payment_count; + } + + /** + * Returns the amount outstanding on a subscription product. + * + * Deprecated because the subscription oustanding balance on a subscription is no longer added and an order can contain more + * than one subscription. + * + * @param WC_Order $order The WC_Order object of the order for which you want to determine the number of failed payments. + * @param product_id int The ID of the subscription product. + * @return string The key representing the given subscription. + * @since 1.0 + */ + public static function get_outstanding_balance( $order, $product_id ) { + _deprecated_function( __METHOD__, '2.0' ); + + $failed_payment_count = self::get_failed_payment_count( $order, $product_id ); + + $oustanding_balance = $failed_payment_count * self::get_recurring_total( $order, $product_id ); + + return $oustanding_balance; + } + + /** + * Once payment is completed on an order, set a lock on payments until the next subscription payment period. + * + * @param int $user_id The id of the user who purchased the subscription + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.1.2 + */ + public static function safeguard_scheduled_payments( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Appends the subscription period/duration string to order total + * + * @since 1.0 + */ + public static function get_formatted_line_total( $formatted_total, $item, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_formatted_line_subtotal()' ); + return $formatted_total; + } + + /** + * Appends the subscription period/duration string to order subtotal + * + * @since 1.0 + */ + public static function get_subtotal_to_display( $subtotal, $compound, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_subtotal_to_display()' ); + return $subtotal; + } + + /** + * Appends the subscription period/duration string to order total + * + * @since 1.0 + */ + public static function get_cart_discount_to_display( $discount, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_discount_to_display()' ); + return $discount; + } + + /** + * Appends the subscription period/duration string to order total + * + * @since 1.0 + */ + public static function get_order_discount_to_display( $discount, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_discount_to_display()' ); + return $discount; + } + + /** + * Appends the subscription period/duration string to order total + * + * @since 1.0 + */ + public static function get_formatted_order_total( $formatted_total, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_formatted_order_total()' ); + return $formatted_total; + } + + /** + * Appends the subscription period/duration string to shipping fee + * + * @since 1.0 + */ + public static function get_shipping_to_display( $shipping_to_display, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_shipping_to_display()' ); + return $shipping_to_display; + } + + /** + * Individual totals are taken care of by filters, but taxes and fees are not, so we need to override them here. + * + * @since 1.0 + */ + public static function get_order_item_totals( $total_rows, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_order_item_totals()' ); + return $total_rows; + } + + /** + * Load Subscription related order data when populating an order + * + * @since 1.4 + */ + public static function load_order_data( $order_data ) { + _deprecated_function( __METHOD__, '2.0' ); + return $order_data; + } + + /** + * Add request filter for order types to Woocommerce -> Orders screen + * + * @since version 1.5.4 + */ + public static function order_shipping_method( $shipping_method, $order ) { + _deprecated_function( __METHOD__, '2.0' ); + return $shipping_method; + } + + /** + * Returns the sign up fee for an item + * + * @param WC_Order $order A WC_Order object + * @param int $product_id The product/post ID of a subscription + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_item_sign_up_fee( $order, $product_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_items_sign_up_fee() or WC_Subscriptions_Order::get_sign_up_fee()' ); + return self::get_sign_up_fee( $order, $product_id ); + } + + /** + * Records the initial payment against a subscription. + * + * This function is called when a gateway calls @see WC_Order::payment_complete() and payment + * is completed on an order. It is also called when an orders status is changed to completed or + * processing for those gateways which never call @see WC_Order::payment_complete(), like the + * core WooCommerce Cheque and Bank Transfer gateways. + * + * It will also set the start date on the subscription to the time the payment is completed. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.1.2 + * @deprecated 2.0 + */ + public static function maybe_record_order_payment( $order ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . 'maybe_record_subscription_payment::( $order, $old_status, $new_status )' ); + + if ( ! wcs_order_contains_renewal( $order ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ); + + foreach ( $subscriptions as $subscription_id => $subscription ) { + + // No payments have been recorded yet + if ( 0 == $subscription->get_completed_payment_count() ) { + $subscription->update_dates( array( 'start' => current_time( 'mysql', true ) ) ); + $subscription->payment_complete(); + } + } + } + } +} +WC_Subscriptions_Order::init(); diff --git a/includes/class-wc-subscriptions-product.php b/includes/class-wc-subscriptions-product.php new file mode 100644 index 0000000..bdde04d --- /dev/null +++ b/includes/class-wc-subscriptions-product.php @@ -0,0 +1,1107 @@ +get_price_including_tax( $qty ); + + remove_filter( 'woocommerce_get_price', __CLASS__ . '::get_sign_up_fee_filter', 100, 2 ); + + return $sign_up_fee_including_tax; + } + + /** + * Returns the raw sign up fee value (ignoring tax) by filtering the products price. + * + * @return string + */ + public static function get_sign_up_fee_filter( $price, $product ) { + + return self::get_sign_up_fee( $product ); + } + + /** + * Returns the sign up fee (excluding tax) by filtering the products price used in + * @see WC_Product::get_price_excluding_tax( $qty ) + * + * @return string + */ + public static function get_sign_up_fee_excluding_tax( $product, $qty = 1 ) { + + add_filter( 'woocommerce_get_price', __CLASS__ . '::get_sign_up_fee_filter', 100, 2 ); + + $sign_up_fee_excluding_tax = $product->get_price_excluding_tax( $qty ); + + remove_filter( 'woocommerce_get_price', __CLASS__ . '::get_sign_up_fee_filter', 100, 2 ); + + return $sign_up_fee_excluding_tax; + } + + /** + * Override the WooCommerce "Add to Cart" text with "Sign Up Now". + * + * @since 1.0 + */ + public static function add_to_cart_text( $button_text, $product_type = '' ) { + global $product; + + if ( self::is_subscription( $product ) || in_array( $product_type, array( 'subscription', 'subscription-variation' ) ) ) { + $button_text = get_option( WC_Subscriptions_Admin::$option_prefix . '_add_to_cart_button_text', __( 'Sign Up Now', 'woocommerce-subscriptions' ) ); + } + + return $button_text; + } + + /** + * Checks a given product to determine if it is a subscription. + * When the received arg is a product object, make sure it is passed into the filter intact in order to retain any properties added on the fly. + * + * @param int|WC_Product $product_id Either a product object or product's post ID. + * @since 1.0 + */ + public static function is_subscription( $product_id ) { + + $is_subscription = false; + + if ( is_object( $product_id ) ) { + $product = $product_id; + $product_id = $product->id; + } elseif ( is_numeric( $product_id ) ) { + $product = wc_get_product( $product_id ); + } + + if ( is_object( $product ) && $product->is_type( array( 'subscription', 'subscription_variation', 'variable-subscription' ) ) ) { + $is_subscription = true; + } + + return apply_filters( 'woocommerce_is_subscription', $is_subscription, $product_id, $product ); + } + + /** + * Output subscription string as the price html for grouped products and make sure that + * sign-up fees are taken into account for price. + * + * @since 1.3.4 + */ + public static function get_grouped_price_html( $price, $grouped_product ) { + + $child_prices = array(); + $contains_subscription = false; + + foreach ( $grouped_product->get_children() as $child_product_id ) { + + if ( self::is_subscription( $child_product_id ) ) { + + $contains_subscription = true; + + $child_product = wc_get_product( $child_product_id ); + + $child_price = $child_product->get_price(); + $sign_up_fee = $child_product->get_sign_up_fee(); + $has_trial = ( self::get_trial_length( $child_product ) > 0 ) ? true : false; + + // Make sure we have the *real* price (i.e. total initial payment) + if ( $has_trial && $sign_up_fee > 0 ) { + $child_price = $sign_up_fee; + } else { + $child_price += $sign_up_fee; + } + + $child_prices[] = $child_price; + + } else { + + $child_prices[] = get_post_meta( $child_product_id, '_price', true ); + + } + } + + if ( ! $contains_subscription ) { + return $price; + } else { + $price = ''; + } + + $child_prices = array_unique( $child_prices ); + + if ( ! empty( $child_prices ) ) { + $min_price = min( $child_prices ); + } else { + $min_price = ''; + } + + if ( sizeof( $child_prices ) > 1 ) { + $price .= $grouped_product->get_price_html_from_text(); + } + + $price .= wc_price( $min_price ); + + return $price; + } + + /** + * Output subscription string in Gravity Form fields. + * + * @since 1.1 + */ + public static function get_gravity_form_prices( $price, $product ) { + + if ( self::is_subscription( $product ) ) { + $price = self::get_price_string( $product, array( 'price' => $price, 'subscription_length' => false, 'sign_up_fee' => false, 'trial_length' => false ) ); + } + + return $price; + } + + /** + * Returns a string representing the details of the subscription. + * + * For example "$20 per Month for 3 Months with a $10 sign-up fee". + * + * @param WC_Product|int $product A WC_Product object or ID of a WC_Product. + * @param array $inclusions An associative array of flags to indicate how to calculate the price and what to include, values: + * 'tax_calculation' => false to ignore tax, 'include_tax' or 'exclude_tax' To indicate that tax should be added or excluded respectively + * 'subscription_length' => true to include subscription's length (default) or false to exclude it + * 'sign_up_fee' => true to include subscription's sign up fee (default) or false to exclude it + * 'price' => string a price to short-circuit the price calculations and use in a string for the product + * @since 1.0 + */ + public static function get_price_string( $product, $include = array() ) { + global $wp_locale; + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) ) { + return; + } + + $include = wp_parse_args( $include, array( + 'tax_calculation' => get_option( 'woocommerce_tax_display_shop' ), + 'subscription_price' => true, + 'subscription_period' => true, + 'subscription_length' => true, + 'sign_up_fee' => true, + 'trial_length' => true, + ) + ); + + $include = apply_filters( 'woocommerce_subscriptions_product_price_string_inclusions', $include, $product ); + + $base_price = self::get_price( $product ); + + if ( true === $include['sign_up_fee'] ) { + $sign_up_fee = self::get_sign_up_fee( $product ); + } elseif ( false !== $include['sign_up_fee'] ) { // Allow override of product's sign-up fee + $sign_up_fee = $include['sign_up_fee']; + } else { + $sign_up_fee = 0; + } + + if ( false != $include['tax_calculation'] ) { + + if ( in_array( $include['tax_calculation'], array( 'exclude_tax', 'excl' ) ) ) { // Subtract Tax + + if ( isset( $include['price'] ) ) { + $price = $include['price']; + } else { + $price = $product->get_price_excluding_tax( 1, $include['price'] ); + } + + if ( true === $include['sign_up_fee'] ) { + $sign_up_fee = self::get_sign_up_fee_excluding_tax( $product ); + } + } else { // Add Tax + + if ( isset( $include['price'] ) ) { + $price = $include['price']; + } else { + $price = $product->get_price_including_tax(); + } + + if ( true === $include['sign_up_fee'] ) { + $sign_up_fee = self::get_sign_up_fee_including_tax( $product ); + } + } + } else { + + if ( isset( $include['price'] ) ) { + $price = $include['price']; + } else { + $price = wc_price( $base_price ); + } + } + + $price .= ' '; + + $billing_interval = self::get_interval( $product ); + $billing_period = self::get_period( $product ); + $subscription_length = self::get_length( $product ); + $trial_length = self::get_trial_length( $product ); + $trial_period = self::get_trial_period( $product ); + + if ( is_numeric( $sign_up_fee ) ) { + $sign_up_fee = wc_price( $sign_up_fee ); + } + + if ( $include['subscription_length'] ) { + $ranges = wcs_get_subscription_ranges( $billing_period ); + } + + if ( $include['subscription_length'] && 0 != $subscription_length ) { + $include_length = true; + } else { + $include_length = false; + } + + $subscription_string = ''; + + if ( $include['subscription_price'] && $include['subscription_period'] ) { // Allow extensions to not show price or billing period e.g. Name Your Price + if ( $include_length && $subscription_length == $billing_interval ) { + $subscription_string = $price; // Only for one billing period so show "$5 for 3 months" instead of "$5 every 3 months for 3 months" + } elseif ( WC_Subscriptions_Synchroniser::is_product_synced( $product ) && in_array( $billing_period, array( 'week', 'month', 'year' ) ) ) { + $payment_day = WC_Subscriptions_Synchroniser::get_products_payment_day( $product ); + switch ( $billing_period ) { + case 'week': + $payment_day_of_week = WC_Subscriptions_Synchroniser::get_weekday( $payment_day ); + if ( 1 == $billing_interval ) { + // translators: 1$: recurring amount string, 2$: day of the week (e.g. "$10 every Wednesday") + $subscription_string = sprintf( __( '%1$s every %2$s', 'woocommerce-subscriptions' ), $price, $payment_day_of_week ); + } else { + // translators: 1$: recurring amount string, 2$: period, 3$: day of the week (e.g. "$10 every 2nd week on Wednesday") + $subscription_string = sprintf( __( '%1$s every %2$s on %3$s', 'woocommerce-subscriptions' ), $price, wcs_get_subscription_period_strings( $billing_interval, $billing_period ), $payment_day_of_week ); + } + break; + case 'month': + if ( 1 == $billing_interval ) { + if ( $payment_day > 27 ) { + // translators: placeholder is recurring amount + $subscription_string = sprintf( __( '%s on the last day of each month', 'woocommerce-subscriptions' ), $price ); + } else { + // translators: 1$: recurring amount, 2$: day of the month (e.g. "23rd") (e.g. "$5 every 23rd of each month") + $subscription_string = sprintf( __( '%1$s on the %2$s of each month', 'woocommerce-subscriptions' ), $price, WC_Subscriptions::append_numeral_suffix( $payment_day ) ); + } + } else { + if ( $payment_day > 27 ) { + // translators: 1$: recurring amount, 2$: interval (e.g. "3rd") (e.g. "$10 on the last day of every 3rd month") + $subscription_string = sprintf( __( '%1$s on the last day of every %2$s month', 'woocommerce-subscriptions' ), $price, WC_Subscriptions::append_numeral_suffix( $billing_interval ) ); + } else { + // translators: 1$: on the, 2$: day of every, 3$: month (e.g. "$10 on the 23rd day of every 2nd month") + $subscription_string = sprintf( __( '%1$s on the %2$s day of every %3$s month', 'woocommerce-subscriptions' ), $price, WC_Subscriptions::append_numeral_suffix( $payment_day ), WC_Subscriptions::append_numeral_suffix( $billing_interval ) ); + } + } + break; + case 'year': + if ( 1 == $billing_interval ) { + // translators: 1$: on, 2$: , 3$: each year (e.g. "$15 on March 15th each year") + $subscription_string = sprintf( __( '%1$s on %2$s %3$s each year', 'woocommerce-subscriptions' ), $price, $wp_locale->month[ $payment_day['month'] ], WC_Subscriptions::append_numeral_suffix( $payment_day['day'] ) ); + } else { + // translators: 1$: recurring amount, 2$: month (e.g. "March"), 3$: day of the month (e.g. "23rd") (e.g. "$15 on March 15th every 3rd year") + $subscription_string = sprintf( __( '%1$s on %2$s %3$s every %4$s year', 'woocommerce-subscriptions' ), $price, $wp_locale->month[ $payment_day['month'] ], WC_Subscriptions::append_numeral_suffix( $payment_day['day'] ), WC_Subscriptions::append_numeral_suffix( $billing_interval ) ); + } + break; + } + } else { + // translators: 1$: recurring amount, 2$: subscription period (e.g. "month" or "3 months") (e.g. "$15 / month" or "$15 every 2nd month") + $subscription_string = sprintf( _n( '%1$s / %2$s', ' %1$s every %2$s', $billing_interval, 'woocommerce-subscriptions' ), $price, wcs_get_subscription_period_strings( $billing_interval, $billing_period ) ); + } + } elseif ( $include['subscription_price'] ) { + $subscription_string = $price; + } elseif ( $include['subscription_period'] ) { + // translators: billing period (e.g. "every week") + $subscription_string = sprintf( __( 'every %s', 'woocommerce-subscriptions' ), wcs_get_subscription_period_strings( $billing_interval, $billing_period ) ); + } + + // Add the length to the end + if ( $include_length ) { + // translators: 1$: subscription string (e.g. "$10 up front then $5 on March 23rd every 3rd year"), 2$: length (e.g. "4 years") + $subscription_string = sprintf( __( '%1$s for %2$s', 'woocommerce-subscriptions' ), $subscription_string, $ranges[ $subscription_length ] ); + } + + if ( $include['trial_length'] && 0 != $trial_length ) { + $trial_string = wcs_get_subscription_trial_period_strings( $trial_length, $trial_period ); + // translators: 1$: subscription string (e.g. "$15 on March 15th every 3 years for 6 years"), 2$: trial length (e.g.: "with 4 months free trial") + $subscription_string = sprintf( __( '%1$s with %2$s free trial', 'woocommerce-subscriptions' ), $subscription_string, $trial_string ); + } + + if ( $include['sign_up_fee'] && self::get_sign_up_fee( $product ) > 0 ) { + // translators: 1$: subscription string (e.g. "$15 on March 15th every 3 years for 6 years with 2 months free trial"), 2$: signup fee price (e.g. "and a $30 sign-up fee") + $subscription_string = sprintf( __( '%1$s and a %2$s sign-up fee', 'woocommerce-subscriptions' ), $subscription_string, $sign_up_fee ); + } + + $subscription_string .= ''; + + return apply_filters( 'woocommerce_subscriptions_product_price_string', $subscription_string, $product, $include ); + } + + /** + * Returns the price per period for a product if it is a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return float The price charged per period for the subscription, or an empty string if the product is not a subscription. + * @since 1.0 + */ + public static function get_price( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) || ( ! isset( $product->subscription_price ) && empty( $product->product_custom_fields['_subscription_price'][0] ) ) ) { + $subscription_price = ''; + } else { + $subscription_price = isset( $product->subscription_price ) ? $product->subscription_price : $product->product_custom_fields['_subscription_price'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_price', $subscription_price, $product ); + } + + /** + * Returns the subscription period for a product, if it's a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return string A string representation of the period, either Day, Week, Month or Year, or an empty string if product is not a subscription. + * @since 1.0 + */ + public static function get_period( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) || ( ! isset( $product->subscription_period ) && empty( $product->product_custom_fields['_subscription_period'][0] ) ) ) { + $subscription_period = ''; + } else { + $subscription_period = isset( $product->subscription_period ) ? $product->subscription_period : $product->product_custom_fields['_subscription_period'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_period', $subscription_period, $product ); + } + + /** + * Returns the subscription interval for a product, if it's a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return string A string representation of the period, either Day, Week, Month or Year, or an empty string if product is not a subscription. + * @since 1.0 + */ + public static function get_interval( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) || ( ! isset( $product->subscription_period_interval ) && empty( $product->product_custom_fields['_subscription_period_interval'][0] ) ) ) { + $subscription_period_interval = 1; + } else { + $subscription_period_interval = isset( $product->subscription_period_interval ) ? $product->subscription_period_interval : $product->product_custom_fields['_subscription_period_interval'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_period_interval', $subscription_period_interval, $product ); + } + + /** + * Returns the length of a subscription product, if it is a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return int An integer representing the length of the subscription, or 0 if the product is not a subscription or the subscription continues for perpetuity + * @since 1.0 + */ + public static function get_length( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) || ( ! isset( $product->subscription_length ) && empty( $product->product_custom_fields['_subscription_length'][0] ) ) ) { + $subscription_length = 0; + } else { + $subscription_length = isset( $product->subscription_length ) ? $product->subscription_length : $product->product_custom_fields['_subscription_length'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_length', $subscription_length, $product ); + } + + /** + * Returns the trial length of a subscription product, if it is a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return int An integer representing the length of the subscription trial, or 0 if the product is not a subscription or there is no trial + * @since 1.0 + */ + public static function get_trial_length( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) || ( ! isset( $product->subscription_trial_length ) && empty( $product->product_custom_fields['_subscription_trial_length'][0] ) ) ) { + $subscription_trial_length = 0; + } else { + $subscription_trial_length = isset( $product->subscription_trial_length ) ? $product->subscription_trial_length : $product->product_custom_fields['_subscription_trial_length'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_trial_length', $subscription_trial_length, $product ); + } + + /** + * Returns the trial period of a subscription product, if it is a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return string A string representation of the period, either Day, Week, Month or Year, or an empty string if product is not a subscription or there is no trial + * @since 1.2 + */ + public static function get_trial_period( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) ) { + $subscription_trial_period = ''; + } elseif ( ! isset( $product->subscription_trial_period ) && empty( $product->product_custom_fields['_subscription_trial_period'][0] ) ) { // Backward compatibility + $subscription_trial_period = self::get_period( $product ); + } else { + $subscription_trial_period = isset( $product->subscription_trial_period ) ? $product->subscription_trial_period : $product->product_custom_fields['_subscription_trial_period'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_trial_period', $subscription_trial_period, $product ); + } + + /** + * Returns the sign-up fee for a subscription, if it is a subscription. + * + * @param mixed $product A WC_Product object or product ID + * @return float The value of the sign-up fee, or 0 if the product is not a subscription or the subscription has no sign-up fee + * @since 1.0 + */ + public static function get_sign_up_fee( $product ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_subscription( $product ) || ( ! isset( $product->subscription_sign_up_fee ) && empty( $product->product_custom_fields['_subscription_sign_up_fee'][0] ) ) ) { + $subscription_sign_up_fee = 0; + } else { + $subscription_sign_up_fee = isset( $product->subscription_sign_up_fee ) ? $product->subscription_sign_up_fee : $product->product_custom_fields['_subscription_sign_up_fee'][0]; + } + + return apply_filters( 'woocommerce_subscriptions_product_sign_up_fee', $subscription_sign_up_fee, $product ); + } + + /** + * Takes a subscription product's ID and returns the date on which the first renewal payment will be processed + * based on the subscription's length and calculated from either the $from_date if specified, or the current date/time. + * + * @param int $product_id The product/post ID of a subscription product + * @param mixed $from_date A MySQL formatted date/time string from which to calculate the expiration date, or empty (default), which will use today's date/time. + * @param string $type The return format for the date, either 'mysql', or 'timezone'. Default 'mysql'. + * @param string $timezone The timezone for the returned date, either 'site' for the site's timezone, or 'gmt'. Default, 'site'. + * @since 2.0 + */ + public static function get_first_renewal_payment_date( $product_id, $from_date = '', $timezone = 'gmt' ) { + + $first_renewal_timestamp = self::get_first_renewal_payment_time( $product_id, $from_date, $timezone ); + + if ( $first_renewal_timestamp > 0 ) { + $first_renewal_date = date( 'Y-m-d H:i:s', $first_renewal_timestamp ); + } else { + $first_renewal_date = 0; + } + + return apply_filters( 'woocommerce_subscriptions_product_first_renewal_payment_date', $first_renewal_date, $product_id, $from_date, $timezone ); + } + + /** + * Takes a subscription product's ID and returns the date on which the first renewal payment will be processed + * based on the subscription's length and calculated from either the $from_date if specified, or the current date/time. + * + * @param int $product_id The product/post ID of a subscription product + * @param mixed $from_date A MySQL formatted date/time string from which to calculate the expiration date, or empty (default), which will use today's date/time. + * @param string $type The return format for the date, either 'mysql', or 'timezone'. Default 'mysql'. + * @param string $timezone The timezone for the returned date, either 'site' for the site's timezone, or 'gmt'. Default, 'site'. + * @since 2.0 + */ + public static function get_first_renewal_payment_time( $product_id, $from_date = '', $timezone = 'gmt' ) { + + if ( ! self::is_subscription( $product_id ) ) { + return 0; + } + + $from_date_param = $from_date; + + $billing_interval = self::get_interval( $product_id ); + $billing_length = self::get_length( $product_id ); + $trial_length = self::get_trial_length( $product_id ); + + if ( $billing_interval !== $billing_length || $trial_length > 0 ) { + + if ( empty( $from_date ) ) { + $from_date = gmdate( 'Y-m-d H:i:s' ); + } + + // If the subscription has a free trial period, the first renewal is the same as the expiration of the free trial + if ( $trial_length > 0 ) { + + $first_renewal_timestamp = strtotime( self::get_trial_expiration_date( $product_id, $from_date ) ); + + } else { + + $from_timestamp = strtotime( $from_date ); + $billing_period = self::get_period( $product_id ); + + if ( 'month' == $billing_period ) { + $first_renewal_timestamp = wcs_add_months( $from_timestamp, $billing_interval ); + } else { + $first_renewal_timestamp = strtotime( "+ $billing_interval {$billing_period}s", $from_timestamp ); + } + + if ( 'site' == $timezone ) { + $first_renewal_timestamp += ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); + } + } + } else { + $first_renewal_timestamp = 0; + } + + return apply_filters( 'woocommerce_subscriptions_product_first_renewal_payment_time', $first_renewal_timestamp, $product_id, $from_date_param, $timezone ); + } + + /** + * Takes a subscription product's ID and returns the date on which the subscription product will expire, + * based on the subscription's length and calculated from either the $from_date if specified, or the current date/time. + * + * @param mixed $product_id The product/post ID of the subscription + * @param mixed $from_date A MySQL formatted date/time string from which to calculate the expiration date, or empty (default), which will use today's date/time. + * @since 1.0 + */ + public static function get_expiration_date( $product_id, $from_date = '' ) { + + $subscription_length = self::get_length( $product_id ); + + if ( $subscription_length > 0 ) { + + if ( empty( $from_date ) ) { + $from_date = gmdate( 'Y-m-d H:i:s' ); + } + + if ( self::get_trial_length( $product_id ) > 0 ) { + $from_date = self::get_trial_expiration_date( $product_id, $from_date ); + } + + $expiration_date = gmdate( 'Y-m-d H:i:s', wcs_add_time( $subscription_length, self::get_period( $product_id ), strtotime( $from_date ) ) ); + + } else { + + $expiration_date = 0; + + } + + return apply_filters( 'woocommerce_subscriptions_product_expiration_date', $expiration_date, $product_id, $from_date ); + } + + /** + * Takes a subscription product's ID and returns the date on which the subscription trial will expire, + * based on the subscription's trial length and calculated from either the $from_date if specified, + * or the current date/time. + * + * @param int $product_id The product/post ID of the subscription + * @param mixed $from_date A MySQL formatted date/time string from which to calculate the expiration date (in UTC timezone), or empty (default), which will use today's date/time (in UTC timezone). + * @since 1.0 + */ + public static function get_trial_expiration_date( $product_id, $from_date = '' ) { + + $trial_period = self::get_trial_period( $product_id ); + $trial_length = self::get_trial_length( $product_id ); + + if ( $trial_length > 0 ) { + + if ( empty( $from_date ) ) { + $from_date = gmdate( 'Y-m-d H:i:s' ); + } + + if ( 'month' == $trial_period ) { + $trial_expiration_date = date( 'Y-m-d H:i:s', wcs_add_months( strtotime( $from_date ), $trial_length ) ); + } else { // Safe to just add the billing periods + $trial_expiration_date = date( 'Y-m-d H:i:s', strtotime( "+ {$trial_length} {$trial_period}s", strtotime( $from_date ) ) ); + } + } else { + + $trial_expiration_date = 0; + + } + + return apply_filters( 'woocommerce_subscriptions_product_trial_expiration_date', $trial_expiration_date, $product_id, $from_date ); + } + + /** + * Checks the classname being used for a product variation to see if it should be a subscription product + * variation, and if so, returns this as the class which should be instantiated (instead of the default + * WC_Product_Variation class). + * + * @return string $classname The name of the WC_Product_* class which should be instantiated to create an instance of this product. + * @since 1.3 + */ + public static function set_subscription_variation_class( $classname, $product_type, $post_type, $product_id ) { + + if ( 'product_variation' === $post_type && 'variation' === $product_type ) { + + $terms = get_the_terms( get_post( $product_id )->post_parent, 'product_type' ); + + $parent_product_type = ! empty( $terms ) && isset( current( $terms )->slug ) ? current( $terms )->slug : ''; + + if ( 'variable-subscription' === $parent_product_type ) { + $classname = 'WC_Product_Subscription_Variation'; + } + } + + return $classname; + } + + /** + * Ensures a price is displayed for subscription variation where WC would normally ignore it (i.e. when prices are equal). + * + * @return array $variation_details Set of name/value pairs representing the subscription. + * @since 1.3.6 + */ + public static function maybe_set_variations_price_html( $variation_details, $variable_product, $variation ) { + + if ( 'variable-subscription' == $variable_product->product_type && empty( $variation_details['price_html'] ) ) { + $variation_details['price_html'] = '' . $variation->get_price_html() . ''; + } + + return $variation_details; + } + + /** + * Do not allow any user to delete a subscription product if it is associated with an order. + * + * Those with appropriate capabilities can still trash the product, but they will not be able to permanently + * delete the product if it is associated with an order (i.e. been purchased). + * + * @since 1.4.9 + */ + public static function user_can_not_delete_subscription( $allcaps, $caps, $args ) { + global $wpdb; + + if ( isset( $args[0] ) && in_array( $args[0], array( 'delete_post', 'delete_product' ) ) && isset( $args[2] ) && ( ! isset( $_GET['action'] ) || 'untrash' != $_GET['action'] ) ) { + + $user_id = $args[2]; + $post_id = $args[2]; + $product = wc_get_product( $post_id ); + + if ( false !== $product && 'trash' == $product->post->post_status && $product->is_type( array( 'subscription', 'variable-subscription', 'subscription_variation' ) ) ) { + + $product_id = ( $product->is_type( 'subscription_variation' ) ) ? $product->post->ID : $post_id; + + $subscription_count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$wpdb->prefix}woocommerce_order_itemmeta` WHERE `meta_key` = '_product_id' AND `meta_value` = %d", $product_id ) ); + + if ( $subscription_count > 0 ) { + $allcaps[ $caps[0] ] = false; + } + } + } + + return $allcaps; + } + + /** + * Make sure the 'untrash' (i.e. "Restore") row action is displayed. + * + * In @see self::user_can_not_delete_subscription() we prevent a store manager being able to delete a subscription product. + * However, WooCommerce also uses the `delete_post` capability to check whether to display the 'trash' and 'untrash' row actions. + * We want a store manager to be able to trash and untrash subscriptions, so this function adds them again. + * + * @return array $actions Array of actions that can be performed on the post. + * @return array $post Array of post values for the current product (or post object if it is not a product). + * @since 1.4.9 + */ + public static function subscription_row_actions( $actions, $post ) { + global $the_product; + + if ( ! empty( $the_product ) && ! isset( $actions['untrash'] ) && $the_product->is_type( array( 'subscription', 'variable-subscription', 'subscription_variation' ) ) ) { + + $post_type_object = get_post_type_object( $post->post_type ); + + if ( 'trash' == $post->post_status && current_user_can( $post_type_object->cap->edit_post, $post->ID ) ) { + $actions['untrash'] = "ID ) ), 'untrash-post_' . $post->ID ) . "'>" . __( 'Restore', 'woocommerce-subscriptions' ) . ''; + } + } + + return $actions; + } + + /** + * Remove the "Delete Permanently" action from the bulk actions select element on the Products admin screen. + * + * Because any subscription products associated with an order can not be permanently deleted (as a result of + * @see self::user_can_not_delete_subscription() ), leaving the bulk action in can lead to the store manager + * hitting the "You are not allowed to delete this item" brick wall and not being able to continue with the + * deletion (or get any more detailed information about which item can't be deleted and why). + * + * @return array $actions Array of actions that can be performed on the post. + * @since 1.4.9 + */ + public static function subscription_bulk_actions( $actions ) { + + unset( $actions['delete'] ); + + return $actions; + } + + /** + * Hooked to the @see 'wp_scheduled_delete' WP-Cron scheduled task to rename the '_wp_trash_meta_time' meta value + * as '_wc_trash_meta_time'. This is the flag used by WordPress to determine which posts should be automatically + * purged from the trash. We want to make sure Subscriptions products are not automatically purged (but still want + * to keep a record of when the product was trashed). + * + * @since 1.4.9 + */ + public static function prevent_scheduled_deletion() { + global $wpdb; + + $query = "UPDATE $wpdb->postmeta + INNER JOIN $wpdb->posts ON $wpdb->postmeta.post_id = $wpdb->posts.ID + SET $wpdb->postmeta.meta_key = '_wc_trash_meta_time' + WHERE $wpdb->postmeta.meta_key = '_wp_trash_meta_time' + AND $wpdb->posts.post_type IN ( 'product', 'product_variation') + AND $wpdb->posts.post_status = 'trash'"; + + $wpdb->query( $query ); + } + + /** + * Trash subscription variations - don't delete them permanently. + * + * This is hooked to 'wp_ajax_woocommerce_remove_variation' & 'wp_ajax_woocommerce_remove_variations' + * before WooCommerce's WC_AJAX::remove_variation() or WC_AJAX::remove_variations() functions are run. + * The WooCommerce functions will still run after this, but if the variation is a subscription, the + * request will either terminate or in the case of bulk deleting, the variation's ID will be removed + * from the $_POST. + * + * @since 1.4.9 + */ + public static function remove_variations() { + + if ( isset( $_POST['variation_id'] ) ) { // removing single variation + + check_ajax_referer( 'delete-variation', 'security' ); + $variation_ids = array( $_POST['variation_id'] ); + + } else { // removing multiple variations + + check_ajax_referer( 'delete-variations', 'security' ); + $variation_ids = (array) $_POST['variation_ids']; + + } + + foreach ( $variation_ids as $index => $variation_id ) { + + $variation_post = get_post( $variation_id ); + + if ( $variation_post && $variation_post->post_type == 'product_variation' ) { + + $variation_product = wc_get_product( $variation_id ); + + if ( $variation_product && $variation_product->is_type( 'subscription_variation' ) ) { + + wp_trash_post( $variation_id ); + + // Prevent WooCommerce deleting the variation + if ( isset( $_POST['variation_id'] ) ) { + die(); + } else { + unset( $_POST['variation_ids'][ $index ] ); + } + } + } + } + } + + /** + * If a product is being marked as not purchasable because it is limited and the customer has a subscription, + * but the current request is to resubscribe to the subscription, then mark it as purchasable. + * + * @since 2.0 + * @return bool + */ + public static function is_purchasable( $is_purchasable, $product ) { + global $wp; + + if ( ! isset( self::$is_purchasable_cache[ $product->id ] ) ) { + + self::$is_purchasable_cache[ $product->id ] = $is_purchasable; + + if ( self::is_subscription( $product->id ) && 'no' != $product->limit_subscriptions && ! wcs_is_order_received_page() && ! wcs_is_paypal_api_page() ) { + + if ( ( ( 'active' == $product->limit_subscriptions && wcs_user_has_subscription( 0, $product->id, 'on-hold' ) ) || wcs_user_has_subscription( 0, $product->id, $product->limit_subscriptions ) ) && ! self::order_awaiting_payment_for_product( $product->id ) ) { + self::$is_purchasable_cache[ $product->id ] = false; + } + } + } + + return self::$is_purchasable_cache[ $product->id ]; + } + + /** + * Save variation meta data when it is bulk edited from the Edit Product screen + * + * @param string $bulk_action The bulk edit action being performed + * @param array $data An array of data relating to the bulk edit action. $data['value'] represents the new value for the meta. + * @param int $variable_product_id The post ID of the parent variable product. + * @param array $variation_ids An array of post IDs for the variable prodcut's variations. + * @since 1.5.29 + */ + public static function bulk_edit_variations( $bulk_action, $data, $variable_product_id, $variation_ids ) { + + if ( WC_Subscriptions::is_woocommerce_pre( '2.5' ) ) { + // Pre 2.5 we don't have the product type information available so we have to check if it is a subscription - downside here is this only works if the product has already been saved + if ( ! self::is_subscription( $variable_product_id ) ) { + return; + } + } else { + // Since 2.5 we have the product type information available so we don't have to wait for the product to be saved to check if it is a subscription + if ( empty( $_POST['security'] ) || ! wp_verify_nonce( $_POST['security'], 'bulk-edit-variations' ) || 'variable-subscription' !== $_POST['product_type'] ) { + return; + } + } + + $meta_key = str_replace( 'variable', '', $bulk_action ); + + // Update the subscription price when updating regular price on a variable subscription product + if ( '_regular_price' == $meta_key ) { + $meta_key = '_subscription_price'; + } + + if ( in_array( $meta_key, self::$subscription_meta_fields ) ) { + foreach ( $variation_ids as $variation_id ) { + update_post_meta( $variation_id, $meta_key, stripslashes( $data['value'] ) ); + } + } else if ( in_array( $meta_key, array( '_regular_price_increase', '_regular_price_decrease' ) ) ) { + $operator = ( '_regular_price_increase' == $meta_key ) ? '+' : '-'; + $value = wc_clean( $data['value'] ); + + foreach ( $variation_ids as $variation_id ) { + $subscription_price = get_post_meta( $variation_id, '_subscription_price', true ); + + if ( '%' === substr( $value, -1 ) ) { + $percent = wc_format_decimal( substr( $value, 0, -1 ) ); + $subscription_price += ( ( $subscription_price / 100 ) * $percent ) * "{$operator}1"; + } else { + $subscription_price += $value * "{$operator}1"; + } + + update_post_meta( $variation_id, '_subscription_price', $subscription_price ); + } + } + } + + /** + * Check if the current session has an order awaiting payment for a subscription to a specific product line item. + * + * @return 2.0.13 + * @return bool + **/ + protected static function order_awaiting_payment_for_product( $product_id ) { + global $wp; + + if ( ! isset( self::$order_awaiting_payment_for_product[ $product_id ] ) ) { + + self::$order_awaiting_payment_for_product[ $product_id ] = false; + + if ( ! empty( WC()->session->order_awaiting_payment ) || isset( $_GET['pay_for_order'] ) ) { + + $order_id = ! empty( WC()->session->order_awaiting_payment ) ? WC()->session->order_awaiting_payment : $wp->query_vars['order-pay']; + $order = wc_get_order( absint( $order_id ) ); + + if ( is_object( $order ) && $order->has_status( array( 'pending', 'failed' ) ) ) { + foreach ( $order->get_items() as $item ) { + if ( $item['product_id'] == $product->id || $item['variation_id'] == $product->id ) { + + $subscriptions = wcs_get_subscriptions( array( + 'order_id' => $order->id, + 'product_id' => $product->id, + ) ); + + if ( ! empty( $subscriptions ) ) { + $subscription = array_pop( $subscriptions ); + + if ( $subscription->has_status( array( 'pending', 'on-hold' ) ) ) { + self::$order_awaiting_payment_for_product[ $product_id ] = true; + } + } + break; + } + } + } + } + } + + return self::$order_awaiting_payment_for_product[ $product_id ]; + } + + /** + * Calculates a price (could be per period price or sign-up fee) for a subscription less tax + * if the subscription is taxable and the prices in the store include tax. + * + * Based on the WC_Product::get_price_excluding_tax() function. + * + * @param float $price The price to adjust based on taxes + * @param WC_Product $product The product the price belongs too (needed to determine tax class) + * @since 1.0 + */ + public static function calculate_tax_for_subscription( $price, $product, $deduct_base_taxes = false ) { + _deprecated_function( __METHOD__, '1.5.8', 'WC_Product::get_price_including_tax()' ); + + if ( $product->is_taxable() ) { + + $tax = new WC_Tax(); + + $base_tax_rates = $tax->get_shop_base_rate( $product->tax_class ); + $tax_rates = $tax->get_rates( $product->get_tax_class() ); // This will get the base rate unless we're on the checkout page + + if ( $deduct_base_taxes && wc_prices_include_tax() ) { + + $base_taxes = $tax->calc_tax( $price, $base_tax_rates, true ); + $taxes = $tax->calc_tax( $price - array_sum( $base_taxes ), $tax_rates, false ); + + } elseif ( get_option( 'woocommerce_prices_include_tax' ) == 'yes' ) { + + $taxes = $tax->calc_tax( $price, $base_tax_rates, true ); + + } else { + + $taxes = $tax->calc_tax( $price, $base_tax_rates, false ); + + } + + $tax_amount = $tax->get_tax_total( $taxes ); + + } else { + + $tax_amount = 0; + + } + + return $tax_amount; + } + + + /** + * Deprecated in favour of native get_price_html() method on the Subscription Product classes (e.g. WC_Product_Subscription) + * + * Output subscription string as the price html + * + * @since 1.0 + * @deprecated 1.5.18 + */ + public static function get_price_html( $price, $product ) { + _deprecated_function( __METHOD__, '1.5.18', __CLASS__ . '::get_price_string()' ); + + if ( self::is_subscription( $product ) ) { + $price = self::get_price_string( $product, array( 'price' => $price ) ); + } + + return $price; + } + + /** + * Deprecated in favour of native get_price_html() method on the Subscription Product classes (e.g. WC_Product_Subscription) + * + * Set the subscription string for products which have a $0 recurring fee, but a sign-up fee + * + * @since 1.3.4 + * @deprecated 1.5.18 + */ + public static function get_free_price_html( $price, $product ) { + _deprecated_function( __METHOD__, '1.5.18', __CLASS__ . '::get_price_string()' ); + + // Check if it has a sign-up fee (we already know it has no recurring fee) + if ( self::is_subscription( $product ) && self::get_sign_up_fee( $product ) > 0 ) { + $price = self::get_price_string( $product, array( 'price' => $price ) ); + } + + return $price; + } +} + +WC_Subscriptions_Product::init(); diff --git a/includes/class-wc-subscriptions-renewal-order.php b/includes/class-wc-subscriptions-renewal-order.php new file mode 100644 index 0000000..eb5b2a4 --- /dev/null +++ b/includes/class-wc-subscriptions-renewal-order.php @@ -0,0 +1,739 @@ +get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_failed_order_replaced_by' AND meta_value = %s", $renewal_order_id ) ); + + return ( null === $failed_order_id ) ? false : $failed_order_id; + } + + /** + * Whenever a renewal order's status is changed, check if a corresponding subscription's status should be changed + * + * This function is hooked to 'woocommerce_order_status_changed', rather than 'woocommerce_payment_complete', to ensure + * subscriptions are updated even if payment is processed by a manual payment gateways (which would never trigger the + * 'woocommerce_payment_complete' hook) or by some other means that circumvents that hook. + * + * @since 2.0 + */ + public static function maybe_record_subscription_payment( $order_id, $orders_old_status, $orders_new_status ) { + + if ( ! wcs_order_contains_renewal( $order_id ) ) { + return; + } + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); + $was_activated = false; + $order_completed = in_array( $orders_new_status, array( apply_filters( 'woocommerce_payment_complete_order_status', 'processing', $order_id ), 'processing', 'completed' ) ); + $order_needed_payment = in_array( $orders_old_status, apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( 'pending', 'on-hold', 'failed' ) ) ); + + if ( $order_completed && $order_needed_payment ) { + $update_post_data = array( + 'ID' => $order_id, + 'post_date' => current_time( 'mysql', 0 ), + 'post_date_gmt' => current_time( 'mysql', 1 ), + ); + + wp_update_post( $update_post_data ); + } + + foreach ( $subscriptions as $subscription ) { + + // Do we need to activate a subscription? + if ( $order_completed && ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) && ! $subscription->has_status( 'active' ) ) { + + if ( $order_needed_payment ) { + $subscription->payment_complete(); + $was_activated = true; + } + + if ( 'failed' === $orders_old_status ) { + do_action( 'woocommerce_subscriptions_paid_for_failed_renewal_order', wc_get_order( $order_id ), $subscription ); + } + } elseif ( 'failed' == $orders_new_status ) { + $subscription->payment_failed(); + } + } + + if ( $was_activated ) { + do_action( 'subscriptions_activated_for_order', $order_id ); + } + } + + /** + * Add order note to subscription to record the renewal order + * + * @param WC_Order|int $renewal_order + * @param WC_Subscription|int $subscription + * @since 2.0 + */ + public static function add_order_note( $renewal_order, $subscription ) { + if ( ! is_object( $subscription ) ) { + $subscription = wcs_get_subscription( $subscription ); + } + + if ( ! is_object( $renewal_order ) ) { + $renewal_order = wc_get_order( $renewal_order ); + } + + if ( is_a( $renewal_order, 'WC_Order' ) && wcs_is_subscription( $subscription ) ) { + + $order_number = sprintf( _x( '#%s', 'hash before order number', 'woocommerce-subscriptions' ), $renewal_order->get_order_number() ); + + // translators: placeholder is order ID + $subscription->add_order_note( sprintf( __( 'Order %s created to record renewal.', 'woocommerce-subscriptions' ), sprintf( '%s ', esc_url( wcs_get_edit_post_link( $renewal_order->id ) ), $order_number ) ) ); + } + + return $renewal_order; + } + + /** + * Do not allow customers to cancel renewal orders. + * + * @since 2.0 + */ + public static function prevent_cancelling_renewal_orders() { + if ( isset( $_GET['cancel_order'] ) && isset( $_GET['order'] ) && isset( $_GET['order_id'] ) ) { + + $order_id = absint( $_GET['order_id'] ); + $order = wc_get_order( $order_id ); + $redirect = $_GET['redirect']; + + if ( wcs_order_contains_renewal( $order ) ) { + remove_action( 'wp_loaded', 'WC_Form_Handler::cancel_order', 20 ); + wc_add_notice( __( 'Subscription renewal orders cannot be cancelled.', 'woocommerce-subscriptions' ), 'notice' ); + + if ( $redirect ) { + wp_safe_redirect( $redirect ); + exit; + } + } + } + } + + /** + * Removes switch line item meta data so it isn't copied to renewal order line items + * + * @since 2.0.16 + * @param array $order_items + * @return array $order_items + */ + public static function remove_switch_item_meta_keys( $order_items ) { + + $switched_order_item_keys = array( + '_switched_subscription_sign_up_fee_prorated' => '', + '_switched_subscription_price_prorated' => '', + '_switched_subscription_item_id' => '', + ); + + foreach ( $order_items as $order_item_id => $item ) { + $order_items[ $order_item_id ]['item_meta'] = array_diff_key( $item['item_meta'], $switched_order_item_keys ); + } + + return $order_items; + } + + /* Deprecated functions */ + + /** + * Hooks to the renewal order created action to determine if the order should be emailed to the customer. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 1.4 + */ + public static function maybe_send_customer_renewal_order_email( $order ) { + _deprecated_function( __METHOD__, '1.4' ); + if ( 'yes' == get_option( WC_Subscriptions_Admin::$option_prefix . '_email_renewal_order' ) ) { + self::send_customer_renewal_order_email( $order ); + } + } + + /** + * Processing Order + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 1.4 + */ + public static function send_customer_renewal_order_email( $order ) { + _deprecated_function( __METHOD__, '1.4' ); + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + $mailer = WC()->mailer(); + $mails = $mailer->get_emails(); + + $mails['WCS_Email_Customer_Renewal_Invoice']->trigger( $order->id ); + } + + /** + * Change the email subject of the new order email to specify the order is a subscription renewal order + * + * @param string $subject The default WooCommerce email subject + * @param WC_Order $order The WC_Order object which the email relates to + * @since 1.2 + * @deprecated 1.4 + */ + public static function email_subject_new_renewal_order( $subject, $order ) { + _deprecated_function( __METHOD__, '1.4' ); + + if ( wcs_order_contains_renewal( $order ) ) { + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + // translators: 1$: blog name, 2$: order number + $subject = apply_filters( 'woocommerce_subscriptions_email_subject_new_renewal_order', sprintf( _x( '[%1$s] New Subscription Renewal Order (%2$s)', 'used in new renewal order email, deprecated', 'woocommerce-subscriptions' ), $blogname, $order->get_order_number() ), $order ); + } + + return $subject; + } + + /** + * Change the email subject of the processing order email to specify the order is a subscription renewal order + * + * @param string $subject The default WooCommerce email subject + * @param WC_Order $order The WC_Order object which the email relates to + * @since 1.2 + * @deprecated 1.4 + */ + public static function email_subject_customer_procesing_renewal_order( $subject, $order ) { + _deprecated_function( __METHOD__, '1.4' ); + + if ( wcs_order_contains_renewal( $order ) ) { + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $subject = apply_filters( + 'woocommerce_subscriptions_email_subject_customer_procesing_renewal_order', + // translators: placeholder is blog name + sprintf( _x( '[%s] Subscription Renewal Order', 'used as email subject for renewal order notification email to customer', 'woocommerce-subscriptions' ), $blogname ), + $order + ); + } + + return $subject; + } + + /** + * Change the email subject of the completed order email to specify the order is a subscription renewal order + * + * @param string $subject The default WooCommerce email subject + * @param WC_Order $order The WC_Order object which the email relates to + * @since 1.2 + * @deprecated 1.4 + */ + public static function email_subject_customer_completed_renewal_order( $subject, $order ) { + _deprecated_function( __METHOD__, '1.4' ); + + if ( wcs_order_contains_renewal( $order ) ) { + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $subject = apply_filters( + 'woocommerce_subscriptions_email_subject_customer_completed_renewal_order', + // translators: placeholder is blog name + sprintf( _x( '[%s] Subscription Renewal Order', 'used as email subject for renewal order notification email to customer', 'woocommerce-subscriptions' ), $blogname ), + $order + ); + } + + return $subject; + } + + /** + * Generate an order to record an automatic subscription payment. + * + * This function is hooked to the 'process_subscription_payment' which is fired when a payment gateway calls + * the @see WC_Subscriptions_Manager::process_subscription_payment() function. Because manual payments will + * also call this function, the function only generates a renewal order if the @see WC_Order::payment_complete() + * will be called for the renewal order. + * + * @param int $user_id The id of the user who purchased the subscription + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.2 + * @deprecated 2.0 + */ + public static function generate_paid_renewal_order( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_create_renewal_order( WC_Subscription $subscription )' ); + $subscription = wcs_get_subscription_from_key( $subscription_key ); + $renewal_order = wcs_create_renewal_order( $subscription ); + $renewal_order->payment_complete(); + return $renewal_order->id; + } + + /** + * Generate an order to record a subscription payment failure. + * + * This function is hooked to the 'processed_subscription_payment_failure' hook called when a payment + * gateway calls the @see WC_Subscriptions_Manager::process_subscription_payment_failure() + * + * @param int $user_id The id of the user who purchased the subscription + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.2 + * @deprecated 2.0 + */ + public static function generate_failed_payment_renewal_order( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_create_renewal_order( WC_Subscription $subscription )' ); + $renewal_order = wcs_create_renewal_order( wcs_get_subscription_from_key( $subscription_key ) ); + $renewal_order->update_status( 'failed' ); + return $renewal_order->id; + } + + /** + * Generate an order to record a subscription payment. + * + * This function is hooked to the scheduled subscription payment hook to create a pending + * order for each scheduled subscription payment. + * + * When a payment gateway calls the @see WC_Subscriptions_Manager::process_subscription_payment() + * @see WC_Order::payment_complete() will be called for the renewal order. + * + * @param int $user_id The id of the user who purchased the subscription + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.2 + */ + public static function maybe_generate_manual_renewal_order( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::maybe_create_manual_renewal_order( WC_Subscription $subscription )' ); + self::maybe_create_manual_renewal_order( wcs_get_subscription_from_key( $subscription_key ) )->id; + } + + /** + * Get the ID of the parent order for a subscription renewal order. + * + * Deprecated because a subscription's details are now stored in a WC_Subscription object, not the + * parent order. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_parent_order_id( $renewal_order ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscriptions_for_renewal_order()' ); + + $parent_order = self::get_parent_order( $renewal_order ); + + return ( null === $parent_order ) ? null : $parent_order->id; + } + + /** + * Get the parent order for a subscription renewal order. + * + * Deprecated because a subscription's details are now stored in a WC_Subscription object, not the + * parent order. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 2.0 self::get_parent_subscription() is the better function to use now as a renewal order + */ + public static function get_parent_order( $renewal_order ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscriptions_for_renewal_order()' ); + + if ( ! is_object( $renewal_order ) ) { + $renewal_order = new WC_Order( $renewal_order ); + } + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order ); + $subscription = array_pop( $subscriptions ); + + if ( false === $subscription->order ) { // There is no original order + $parent_order = null; + } else { + $parent_order = $subscription->order; + } + + return apply_filters( 'woocommerce_subscriptions_parent_order', $parent_order, $renewal_order ); + } + + /** + * Returns the number of renewals for a given parent order + * + * @param int $order_id The ID of a WC_Order object. + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_renewal_order_count( $order_id ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_related_orders()' ); + + $subscriptions_for_order = wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'parent' ) ); + + if ( ! empty( $subscriptions_for_order ) ) { + + $subscription = array_pop( $subscriptions_for_order ); + $all_orders = $subscription->get_related_orders(); + + $renewal_order_count = count( $all_orders ); + + // Don't include the initial order (if any) + if ( false !== $subscription->order ) { + $renewal_order_count -= 1; + } + } else { + $renewal_order_count = 0; + } + + return apply_filters( 'woocommerce_subscriptions_renewal_order_count', $renewal_order_count, $order_id ); + } + + /** + * Returns a URL including required parameters for an authenticated user to renew a subscription + * + * Deprecated because the use of a $subscription_key is deprecated. + * + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_users_renewal_link( $subscription_key, $role = 'parent' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_users_resubscribe_link( $subscription )' ); + return wcs_get_users_resubscribe_link( wcs_get_subscription_from_key( $subscription_key ) ); + } + + /** + * Returns a URL including required parameters for an authenticated user to renew a subscription by product ID. + * + * Deprecated because the use of a $subscription_key is deprecated. + * + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_users_renewal_link_for_product( $product_id ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_get_users_resubscribe_link_for_product( $subscription )' ); + return wcs_get_users_resubscribe_link_for_product( $product_id ); + } + + /** + * Check if a given subscription can be renewed. + * + * Deprecated because the use of a $subscription_key is deprecated. + * + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @param int $user_id The ID of the user who owns the subscriptions. Although this parameter is optional, if you have the User ID you should pass it to improve performance. + * @since 1.2 + * @deprecated 2.0 + */ + public static function can_subscription_be_renewed( $subscription_key, $user_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_can_user_resubscribe_to( $subscription, $user_id )' ); + return wcs_can_user_resubscribe_to( wcs_get_subscription_from_key( $subscription_key ), $user_id ); + } + + /** + * Checks if the current request is by a user to renew their subscription, and if it is + * set up a subscription renewal via the cart for the product/variation that is being renewed. + * + * @since 1.2 + * @deprecated 2.0 + */ + public static function maybe_create_renewal_order_for_user() { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::maybe_setup_resubscribe_via_cart()' ); + } + + /** + * When restoring the cart from the session, if the cart item contains addons, but is also + * a subscription renewal, do not adjust the price because the original order's price will + * be used, and this includes the addons amounts. + * + * @since 1.5.5 + * @deprecated 2.0 + */ + public static function product_addons_adjust_price( $adjust_price, $cart_item ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::product_addons_adjust_price()' ); + } + + /** + * Created a new order for renewing a subscription product based on the details of a previous order. + * + * @param WC_Order|int $order The WC_Order object or ID of the order for which the a new order should be created. + * @param string $product_id The ID of the subscription product in the order which needs to be added to the new order. + * @param array $args (optional) An array of name => value flags: + * 'new_order_role' string A flag to indicate whether the new order should become the master order for the subscription. Accepts either 'parent' or 'child'. Defaults to 'parent' - replace the existing order. + * 'checkout_renewal' bool Indicates if invoked from an interactive cart/checkout session and certain order items are not set, like taxes, shipping as they need to be set in teh calling function, like @see WC_Subscriptions_Checkout::filter_woocommerce_create_order(). Default false. + * 'failed_order_id' int For checkout_renewal true, indicates order id being replaced + * @since 1.2 + * @deprecated 2.0 + */ + public static function generate_renewal_order( $original_order, $product_id, $args = array() ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_create_renewal_order() or wcs_create_resubscribe_order()' ); + + if ( ! wcs_order_contains_subscription( $original_order, 'parent' ) ) { + return false; + } + + $args = wp_parse_args( $args, array( + 'new_order_role' => 'parent', + 'checkout_renewal' => false, + ) + ); + + $subscriptions = wcs_get_subscriptions_for_order( $original_order, array( 'order_type' => 'parent' ) ); + $subscription = array_shift( $subscriptions ); + + if ( 'parent' == $args['new_order_role'] ) { + $new_order = wcs_create_resubscribe_order( $subscription ); + } else { + $new_order = wcs_create_renewal_order( $subscription ); + } + + return $new_order->id; + } + + /** + * If a product is being marked as not purchasable because it is limited and the customer has a subscription, + * but the current request is to resubscribe to the subscription, then mark it as purchasable. + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function is_purchasable( $is_purchasable, $product ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::is_purchasable()' ); + return $is_purchasable; + } + + /** + * Check if a given order is a subscription renewal order and optionally, if it is a renewal order of a certain role. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @param array $args (optional) An array of name => value flags: + * 'order_role' string (optional) A specific role to check the order against. Either 'parent' or 'child'. + * 'via_checkout' Indicates whether to check if the renewal order was via the cart/checkout process. + * @since 1.2 + */ + public static function is_renewal( $order, $args = array() ) { + + $args = wp_parse_args( $args, array( + 'order_role' => '', + 'via_checkout' => false, + ) + ); + + $is_resubscribe_order = wcs_order_contains_resubscribe( $order ); + $is_renewal_order = wcs_order_contains_renewal( $order ); + + if ( empty( $args['new_order_role'] ) ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_order_contains_resubscribe( $order ) and wcs_order_contains_renewal( $order )' ); + return ( $is_resubscribe_order || $is_renewal_order ); + } elseif ( 'parent' == $args['new_order_role'] ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_order_contains_resubscribe( $order )' ); + return $is_resubscribe_order; + } else { + _deprecated_function( __METHOD__, '2.0', 'wcs_order_contains_renewal( $order )' ); + return $is_renewal_order; + } + } + + /** + * Returns the renewal orders for a given parent order + * + * @param int $order_id The ID of a WC_Order object. + * @param string $output (optional) How you'd like the result. Can be 'ID' for IDs only or 'WC_Order' for order objects. + * @since 1.2 + * @deprecated 2.0 + */ + public static function get_renewal_orders( $order_id, $output = 'ID' ) { + _deprecated_function( __METHOD__, '2.0', 'WC_Subscription::get_related_orders()' ); + + $subscriptions = wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'parent' ) ); + $subscription = array_shift( $subscriptions ); + + if ( 'WC_Order' == $output ) { + + $renewal_orders = $subscription->get_related_orders( 'all', 'renewal' ); + + } else { + + $renewal_orders = $subscription->get_related_orders( 'ids', 'renewal' ); + + } + + return apply_filters( 'woocommerce_subscriptions_renewal_orders', $renewal_orders, $order_id ); + } + + /** + * Flag payment of manual renewal orders. + * + * This is particularly important to ensure renewals of limited subscriptions can be completed. + * + * @since 1.5.5 + * @deprecated 2.0 + */ + public static function get_checkout_payment_url( $pay_url, $order ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::get_checkout_payment_url() or WCS_Cart_Resubscribe::get_checkout_payment_url()' ); + return $pay_url; + } + + /** + * Process a renewal payment when a customer has completed the payment for a renewal payment which previously failed. + * + * @since 1.3 + * @deprecated 2.0 + */ + public static function maybe_process_failed_renewal_order_payment( $order_id ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Cart_Renewal::maybe_change_subscription_status( $order_id, $orders_old_status, $orders_new_status )' ); + } + + /** + * If the payment for a renewal order has previously failed and is then paid, then the + * @see WC_Subscriptions_Manager::process_subscription_payments_on_order() function would + * never be called. This function makes sure it is called. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 2.0 + */ + public static function process_failed_renewal_order_payment( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + if ( wcs_order_contains_renewal( $order_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); + $subscription = array_pop( $subscriptions ); + + if ( $subscription->is_manual() ) { + add_action( 'woocommerce_payment_complete', __CLASS__ . '::process_subscription_payment_on_child_order', 10, 1 ); + } + } + } + + /** + * Records manual payment of a renewal order against a subscription. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 2.0 + */ + public static function maybe_record_renewal_order_payment( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + if ( wcs_order_contains_renewal( $order_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); + $subscription = array_pop( $subscriptions ); + + if ( $subscription->is_manual() ) { + self::process_subscription_payment_on_child_order( $order_id ); + } + } + } + + /** + * Records manual payment of a renewal order against a subscription. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.2 + * @deprecated 2.0 + */ + public static function maybe_record_renewal_order_payment_failure( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + if ( wcs_order_contains_renewal( $order_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); + $subscription = array_pop( $subscriptions ); + + if ( $subscription->is_manual() ) { + self::process_subscription_payment_on_child_order( $order_id, 'failed' ); + } + } + } + + /** + * If the payment for a renewal order has previously failed and is then paid, we need to make sure the + * subscription payment function is called. + * + * @param int $user_id The id of the user who purchased the subscription + * @param string $subscription_key A subscription key of the form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @since 1.2 + * @deprecated 2.0 + */ + public static function process_subscription_payment_on_child_order( $order_id, $payment_status = 'completed' ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( wcs_order_contains_renewal( $order_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); + + foreach ( $subscriptions as $subscription ) { + + if ( 'failed' == $payment_status ) { + + $subscription->payment_failed(); + + } else { + + $subscription->payment_complete(); + + $subscription->update_status( 'active' ); + } + } + } + } + + /** + * Adds a renewal orders section to the Related Orders meta box displayed on subscription orders. + * + * @deprecated 2.0 + * @since 1.2 + */ + public static function renewal_orders_meta_box_section( $order, $post ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Trigger a hook when a subscription suspended due to a failed renewal payment is reactivated + * + * @since 1.3 + */ + public static function trigger_processed_failed_renewal_order_payment_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::maybe_record_subscription_payment( $order_id, $orders_old_status, $orders_new_status )' ); + } +} +WC_Subscriptions_Renewal_Order::init(); diff --git a/includes/class-wc-subscriptions-switcher.php b/includes/class-wc-subscriptions-switcher.php new file mode 100644 index 0000000..0b6aa0b --- /dev/null +++ b/includes/class-wc-subscriptions-switcher.php @@ -0,0 +1,2010 @@ + $switch_item ) { + + $subscription = wcs_get_subscription( $switch_item['subscription_id'] ); + + if ( ! is_object( $subscription ) || ! current_user_can( 'switch_shop_subscription', $subscription->id ) || ! wcs_is_product_switchable_type( WC()->cart->cart_contents[ $cart_item_key ]['data'] ) ) { + WC()->cart->remove_cart_item( $cart_item_key ); + $removed_item_count++; + } + } + + if ( $removed_item_count > 0 ) { + WC_Subscriptions::add_notice( _n( 'Your cart contained an invalid subscription switch request. It has been removed.', 'Your cart contained invalid subscription switch requests. They have been removed.', $removed_item_count, 'woocommerce-subscriptions' ), 'error' ); + + wp_redirect( WC()->cart->get_cart_url() ); + exit(); + } + } elseif ( is_product() && $product = wc_get_product( $post ) ) { // Automatically initiate the switch process for limited variable subscriptions + + if ( wcs_is_product_switchable_type( $product ) && 'no' != $product->limit_subscriptions ) { + + // Check if the user has an active subscription for this product, and if so, initiate the switch process + $subscriptions = wcs_get_users_subscriptions(); + + $child_ids = $product->get_children(); + + foreach ( $subscriptions as $subscription ) { + + // If we're on a grouped product's page, we need to check if the subscription has a child of this grouped product that needs to be switched + $subscription_product_id = false; + + if ( $product->is_type( 'grouped' ) ) { + foreach ( $child_ids as $child_id ) { + if ( $subscription->has_product( $child_id ) ) { + $subscription_product_id = $child_id; + break; + } + } + } + + if ( $subscription->has_product( $product->id ) || $subscription_product_id ) { + + // For grouped products, we need to check the child products limitations, not the grouped product's (which will have no limitation) + if ( $subscription_product_id ) { + $child_product = wc_get_product( $subscription_product_id ); + $limitation = $child_product->limit_subscriptions; + } else { + $limitation = $product->limit_subscriptions; + } + + // If the product is limited + if ( 'any' == $limitation || $subscription->has_status( $limitation ) || ( 'active' == $limitation && $subscription->has_status( 'on-hold' ) ) ) { + + $subscribed_notice = __( 'You have already subscribed to this product and it is limited to one per customer. You can not purchase the product again.', 'woocommerce-subscriptions' ); + + // If switching is enabled for this product type, initiate the auto-switch process + if ( wcs_is_product_switchable_type( $product ) ) { + + // Don't initiate auto-switching when the subscription requires payment + if ( $subscription->needs_payment() ) { + + $last_order = $subscription->get_last_order( 'all' ); + + if ( $last_order->needs_payment() ) { + // translators: 1$: is the "You have already subscribed to this product" notice, 2$-4$: opening/closing link tags, 3$: an order number + $subscribed_notice = sprintf( __( '%1$s Complete payment on %2$sOrder %3$s%4$s to be able to change your subscription.', 'woocommerce-subscriptions' ), $subscribed_notice, sprintf( '', $last_order->get_checkout_payment_url() ), $last_order->get_order_number(), '' ); + } + + WC_Subscriptions::add_notice( $subscribed_notice, 'notice' ); + break; + + } else { + + $product_id = ( $subscription_product_id ) ? $subscription_product_id : $product->id; + + // Get the matching item + foreach ( $subscription->get_items() as $line_item_id => $line_item ) { + if ( $line_item['product_id'] == $product_id || $line_item['variation_id'] == $product_id ) { + $item_id = $line_item_id; + $item = $line_item; + break; + } + } + + wp_redirect( add_query_arg( 'auto-switch', 'true', self::get_switch_url( $item_id, $item, $subscription ) ) ); + exit; + } + } else { + + WC_Subscriptions::add_notice( $subscribed_notice, 'notice' ); + break; + } + } + } + } + } + } + } + + /** + * When switching between grouped products, the Switch Subscription will take people to the grouped product's page. From there if they + * click through to the individual products, they lose the switch. + * + * WooCommerce added a filter so we're able to modify the permalinks, passing through the switch parameter to the individual products' + * pages. + * + * @param string $permalink The permalink of the product belonging to that group + */ + public static function add_switch_query_arg_grouped( $permalink ) { + + if ( isset( $_GET['switch-subscription'] ) ) { + $permalink = self::add_switch_query_args( $_GET['switch-subscription'], $_GET['item'], $permalink ); + } + + return $permalink; + } + + /** + * Slightly more awkward implementation for WooCommerce versions that do not have the woocommerce_grouped_product_list_link filter. + * + * @param string $permalink The permalink of the product belonging to the group + * @param object $post a WP_Post object + * @return string modified string with the query arg present + */ + public static function add_switch_query_arg_post_link( $permalink, $post ) { + + if ( ! isset( $_GET['switch-subscription'] ) || ! is_main_query() || ! is_product() || 'product' !== $post->post_type ) { + return $permalink; + } + + $product = wc_get_product( $post ); + + if ( ! $product->is_type( 'subscription' ) ) { + return $permalink; + } + + return self::add_switch_query_args( $_GET['switch-subscription'], $_GET['item'], $permalink ); + } + + /** + * Add Switch settings to the Subscription's settings page. + * + * @since 1.4 + */ + public static function add_settings( $settings ) { + + array_splice( $settings, 12, 0, array( + + array( + 'name' => __( 'Switching', 'woocommerce-subscriptions' ), + 'type' => 'title', + // translators: placeholders are opening and closing link tags + 'desc' => sprintf( __( 'Allow subscribers to switch (upgrade or downgrade) between different subscriptions. %sLearn more%s.', 'woocommerce-subscriptions' ), '', '' ), + 'id' => WC_Subscriptions_Admin::$option_prefix . '_switch_settings', + ), + + array( + 'name' => __( 'Allow Switching', 'woocommerce-subscriptions' ), + 'desc' => __( 'Allow subscribers to switch between subscriptions combined in a grouped product, different variations of a Variable subscription or don\'t allow switching.', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => WC_Subscriptions_Admin::$option_prefix . '_allow_switching', + 'css' => 'min-width:150px;', + 'default' => 'no', + 'type' => 'select', + 'options' => array( + 'no' => _x( 'Never', 'when to allow a setting', 'woocommerce-subscriptions' ), + 'variable' => _x( 'Between Subscription Variations', 'when to allow switching', 'woocommerce-subscriptions' ), + 'grouped' => _x( 'Between Grouped Subscriptions', 'when to allow switching', 'woocommerce-subscriptions' ), + 'variable_grouped' => _x( 'Between Both Variations & Grouped Subscriptions', 'when to allow switching', 'woocommerce-subscriptions' ), + ), + 'desc_tip' => true, + ), + + array( + 'name' => __( 'Prorate Recurring Payment', 'woocommerce-subscriptions' ), + 'desc' => __( 'When switching to a subscription with a different recurring payment or billing period, should the price paid for the existing billing period be prorated when switching to the new subscription?', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => WC_Subscriptions_Admin::$option_prefix . '_apportion_recurring_price', + 'css' => 'min-width:150px;', + 'default' => 'no', + 'type' => 'select', + 'options' => array( + 'no' => _x( 'Never', 'when to allow a setting', 'woocommerce-subscriptions' ), + 'virtual-upgrade' => _x( 'For Upgrades of Virtual Subscription Products Only', 'when to prorate recurring fee when switching', 'woocommerce-subscriptions' ), + 'yes-upgrade' => _x( 'For Upgrades of All Subscription Products', 'when to prorate recurring fee when switching', 'woocommerce-subscriptions' ), + 'virtual' => _x( 'For Upgrades & Downgrades of Virtual Subscription Products Only', 'when to prorate recurring fee when switching', 'woocommerce-subscriptions' ), + 'yes' => _x( 'For Upgrades & Downgrades of All Subscription Products', 'when to prorate recurring fee when switching', 'woocommerce-subscriptions' ), + ), + 'desc_tip' => true, + ), + + array( + 'name' => __( 'Prorate Sign up Fee', 'woocommerce-subscriptions' ), + 'desc' => __( 'When switching to a subscription with a sign up fee, you can require the customer pay only the gap between the existing subscription\'s sign up fee and the new subscription\'s sign up fee (if any).', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => WC_Subscriptions_Admin::$option_prefix . '_apportion_sign_up_fee', + 'css' => 'min-width:150px;', + 'default' => 'no', + 'type' => 'select', + 'options' => array( + 'no' => _x( 'Never (do not charge a sign up fee)', 'when to prorate signup fee when switching', 'woocommerce-subscriptions' ), + 'full' => _x( 'Never (charge the full sign up fee)', 'when to prorate signup fee when switching', 'woocommerce-subscriptions' ), + 'yes' => _x( 'Always', 'when to prorate signup fee when switching','woocommerce-subscriptions' ), + ), + 'desc_tip' => true, + ), + + array( + 'name' => __( 'Prorate Subscription Length', 'woocommerce-subscriptions' ), + 'desc' => __( 'When switching to a subscription with a length, you can take into account the payments already completed by the customer when determining how many payments the subscriber needs to make for the new subscription.', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => WC_Subscriptions_Admin::$option_prefix . '_apportion_length', + 'css' => 'min-width:150px;', + 'default' => 'no', + 'type' => 'select', + 'options' => array( + 'no' => _x( 'Never', 'when to allow a setting', 'woocommerce-subscriptions' ), + 'virtual' => _x( 'For Virtual Subscription Products Only', 'when to prorate first payment / subscription length', 'woocommerce-subscriptions' ), + 'yes' => _x( 'For All Subscription Products', 'when to prorate first payment / subscription length', 'woocommerce-subscriptions' ), + ), + 'desc_tip' => true, + ), + + array( + 'name' => __( 'Switch Button Text', 'woocommerce-subscriptions' ), + 'desc' => __( 'Customise the text displayed on the button next to the subscription on the subscriber\'s account page. The default is "Switch Subscription", but you may wish to change this to "Upgrade" or "Change Subscription".', 'woocommerce-subscriptions' ), + 'tip' => '', + 'id' => WC_Subscriptions_Admin::$option_prefix . '_switch_button_text', + 'css' => 'min-width:150px;', + 'default' => __( 'Upgrade or Downgrade', 'woocommerce-subscriptions' ), + 'type' => 'text', + 'desc_tip' => true, + ), + + array( 'type' => 'sectionend', 'id' => WC_Subscriptions_Admin::$option_prefix . '_switch_settings' ), + ) ); + + return $settings; + } + + /** + * Adds an Upgrade/Downgrade link on the View Subscription page for each item that can be switched. + * + * @param int $item_id The order item ID of a subscription line item + * @param array $item An order line item + * @param object $subscription A WC_Subscription object + * @since 1.4 + */ + public static function print_switch_link( $item_id, $item, $subscription ) { + + if ( 'shop_subscription' !== $subscription->order_type || ! self::can_item_be_switched_by_user( $item, $subscription ) ) { + return; + } + + $switch_url = esc_url( self::get_switch_url( $item_id, $item, $subscription ) ); + $switch_text = get_option( WC_Subscriptions_Admin::$option_prefix . '_switch_button_text', __( 'Upgrade or Downgrade', 'woocommerce-subscriptions' ) ); + $switch_link = sprintf( '%s', $switch_url, $switch_text ); + + echo wp_kses( apply_filters( 'woocommerce_subscriptions_switch_link', $switch_link, $item_id, $item, $subscription ), array( 'a' => array( 'href' => array(), 'title' => array(), 'class' => array() ) ) ); + } + + /** + * The link for switching a subscription - the product page for variable subscriptions, or grouped product page for grouped subscriptions. + * + * @param WC_Subscription $subscription An instance of WC_Subscription + * @param array $item An order item on the subscription + * @since 2.0 + */ + public static function get_switch_url( $item_id, $item, $subscription ) { + + if ( ! is_object( $subscription ) ) { + $subscription = wcs_get_subscription( $subscription ); + } + + $product = wc_get_product( $item['product_id'] ); + + // Grouped product + if ( 0 !== $product->post->post_parent ) { + $switch_url = get_permalink( $product->post->post_parent ); + } else { + $switch_url = get_permalink( $product->id ); + } + + $switch_url = self::add_switch_query_args( $subscription->id, $item_id, $switch_url ); + + return apply_filters( 'woocommerce_subscriptions_switch_url', $switch_url, $item_id, $item, $subscription ); + } + + /** + * Add the switch parameters to a URL for a given subscription and item. + * + * @param int $subscription_id A subscription's post ID + * @param int $item_id The order item ID of a subscription line item + * @param string $permalink The permalink of the product + * @since 2.0 + */ + protected static function add_switch_query_args( $subscription_id, $item_id, $permalink ) { + + // manually add a nonce because we can't use wp_nonce_url() (it would escape the URL) + $permalink = add_query_arg( array( 'switch-subscription' => absint( $subscription_id ), 'item' => absint( $item_id ), '_wcsnonce' => wp_create_nonce( 'wcs_switch_request' ) ), $permalink ); + + return apply_filters( 'woocommerce_subscriptions_add_switch_query_args', $permalink, $subscription_id, $item_id ); + } + + /** + * Check if a given item on a subscription can be switched. + * + * For an item to be switchable, switching must be enabled, and the item must be for a variable subscription or + * part of a grouped product (at the time the check is made, not at the time the subscription was purchased) + * + * The subscription must also be active or on-hold and use manual renewals or use a payment method which supports cancellation. + * + * @param array $item An order item on the subscription + * @param WC_Subscription $subscription An instance of WC_Subscription + * @since 2.0 + */ + public static function can_item_be_switched( $item, $subscription = null ) { + + $product_id = wcs_get_canonical_product_id( $item ); + + if ( 'line_item' == $item['type'] && wcs_is_product_switchable_type( $product_id ) ) { + $is_product_switchable = true; + } else { + $is_product_switchable = false; + } + + if ( $subscription->has_status( 'active' ) && 0 !== $subscription->get_date( 'last_payment' ) ) { + $is_subscription_switchable = true; + } else { + $is_subscription_switchable = false; + } + + if ( $subscription->payment_method_supports( 'subscription_amount_changes' ) && $subscription->payment_method_supports( 'subscription_date_changes' ) ) { + $can_subscription_be_updated = true; + } else { + $can_subscription_be_updated = false; + } + + if ( $is_product_switchable && $is_subscription_switchable && $can_subscription_be_updated ) { + $item_can_be_switch = true; + } else { + $item_can_be_switch = false; + } + + return apply_filters( 'woocommerce_subscriptions_can_item_be_switched', $item_can_be_switch, $item, $subscription ); + } + + /** + * Check if a given item on a subscription can be switched by a given user. + * + * @param array $item An order item on the subscription + * @param WC_Subscription $subscription An instance of WC_Subscription + * @param int $user_id (optional) The ID of a user. Defaults to currently logged in user. + * @since 2.0 + */ + public static function can_item_be_switched_by_user( $item, $subscription, $user_id = 0 ) { + + if ( 0 === $user_id ) { + $user_id = get_current_user_id(); + } + + $item_can_be_switched = false; + + if ( user_can( $user_id, 'switch_shop_subscription', $subscription->id ) && self::can_item_be_switched( $item, $subscription ) ) { + $item_can_be_switched = true; + } + + return apply_filters( 'woocommerce_subscriptions_can_item_be_switched_by_user', $item_can_be_switched, $item, $subscription ); + } + + /** + * If the order being generated is for switching a subscription, keep a record of some of the switch + * routines meta against the order. + * + * @param int $order_id The ID of a WC_Order object + * @param array $posted The data posted on checkout + * @since 1.4 + */ + public static function add_order_meta( $order_id, $posted ) { + + $switches = self::cart_contains_switches(); + + if ( false !== $switches ) { + foreach ( $switches as $switch_details ) { + add_post_meta( $order_id, '_subscription_switch', $switch_details['subscription_id'] ); + } + } + } + + /** + * To prorate sign-up fee and recurring amounts correctly when the customer switches a subscription multiple times, keep a record of the + * amount for each on the order item. + * + * @since 2.0 + */ + public static function add_order_item_meta( $order_item_id, $cart_item, $cart_item_key ) { + + if ( isset( $cart_item['subscription_switch'] ) ) { + if ( $switches = self::cart_contains_switches() ) { + foreach ( $switches as $switch_item_key => $switch_details ) { + if ( $cart_item_key == $switch_item_key ) { + wc_add_order_item_meta( $order_item_id, '_switched_subscription_sign_up_fee_prorated', ( isset( WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee_prorated ) ? WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee_prorated : 0 ), true ); + wc_add_order_item_meta( $order_item_id, '_switched_subscription_price_prorated', ( isset( WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_price_prorated ) ? WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_price_prorated : 0 ), true ); + } + } + } + } + } + + /** + * Subscription items on a new billing schedule are left to be added as new subscriptions, but we also + * want to keep a record of them being a switch, so we do that here. + * + * @since 2.0 + */ + public static function set_subscription_item_meta( $item_id, $cart_item, $cart_item_key ) { + + if ( isset( $cart_item['subscription_switch'] ) ) { + if ( $switches = self::cart_contains_switches() ) { + foreach ( $switches as $switch_item_key => $switch_details ) { + if ( $cart_item_key == $switch_item_key ) { + wc_add_order_item_meta( $item_id, '_switched_subscription_item_id', $switch_details['item_id'], true ); + wc_add_order_item_meta( $switch_details['item_id'], '_switched_subscription_new_item_id', $item_id, true ); + } + } + } + } + } + + /** + * Handle any subscription switch items on checkout (and before WC_Subscriptions_Checkout::process_checkout()) + * + * If the item is on the same billing schedule as the old subscription (and the next payment date is the same) or the + * item is the only item on the subscription, the subscription item will be updated (and a note left on the order). + * If the item is on a new billing schedule and there are other items on the existing subscription, the old item will + * be removed and the new item will be added to a new subscription by @see WC_Subscriptions_Checkout::process_checkout() + * + * @param int $order_id The post_id of a shop_order post/WC_Order object + * @param array $posted_data The data posted on checkout + * @since 2.0 + */ + public static function process_checkout( $order_id, $posted_data ) { + global $wpdb; + + if ( ! WC_Subscriptions_Cart::cart_contains_subscription() ) { + return; + } + + $order = wc_get_order( $order_id ); + + try { + // Start transaction if available + $wpdb->query( 'START TRANSACTION' ); + + foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { + + foreach ( $recurring_cart->get_cart() as $cart_item_key => $cart_item ) { + + if ( ! isset( $cart_item['subscription_switch']['subscription_id'] ) ) { + continue; + } + + $subscription = wcs_get_subscription( $cart_item['subscription_switch']['subscription_id'] ); + $existing_item = wcs_get_order_item( $cart_item['subscription_switch']['item_id'], $subscription ); + + // If there are no more payments due on the subscription, because we're in the last billing period, we need to use the subscription's expiration date, not next payment date + if ( 0 == ( $next_payment_timestamp = $subscription->get_time( 'next_payment' ) ) ) { + $next_payment_timestamp = $subscription->get_time( 'end' ); + } + + if ( $cart_item['data']->subscription_period != $subscription->billing_period || $cart_item['data']->subscription_period_interval != $subscription->billing_interval ) { + $is_different_billing_schedule = true; + } else { + $is_different_billing_schedule = false; + } + + if ( 0 !== $cart_item['subscription_switch']['first_payment_timestamp'] && $next_payment_timestamp !== $cart_item['subscription_switch']['first_payment_timestamp'] ) { + $is_different_payment_date = true; + } elseif ( 0 !== $cart_item['subscription_switch']['first_payment_timestamp'] && 0 == $subscription->get_time( 'next_payment' ) ) { // if the subscription doesn't have a next payment but the switched item does + $is_different_payment_date = true; + } else { + $is_different_payment_date = false; + } + + if ( date( 'Y-m-d', strtotime( $recurring_cart->end_date ) ) !== date( 'Y-m-d', $subscription->get_time( 'end' ) ) ) { + $is_different_length = true; + } else { + $is_different_length = false; + } + + // WC_Abstract_Order::get_item_count() uses quantites, not just line item rows + if ( 1 == count( $subscription->get_items() ) ) { + $is_single_item_subscription = true; + } else { + $is_single_item_subscription = false; + } + + // If the item is on the same schedule, we can just add it to the new subscription and remove the old item + if ( $is_single_item_subscription || ( false === $is_different_billing_schedule && false === $is_different_payment_date && false === $is_different_length ) ) { + + // Add the new item + $item_id = WC_Subscriptions_Checkout::add_cart_item( $subscription, $cart_item, $cart_item_key ); + + // Remove the item from the cart so that WC_Subscriptions_Checkout doesn't add it to a subscription + if ( 1 == count( WC()->cart->recurring_carts[ $recurring_cart_key ]->get_cart() ) ) { + // If this is the only item in the cart, clear out recurring carts so WC_Subscriptions_Checkout doesn't try to create an empty subscription + unset( WC()->cart->recurring_carts[ $recurring_cart_key ] ); + } else { + unset( WC()->cart->recurring_carts[ $recurring_cart_key ]->cart_contents[ $cart_item_key ] ); + } + + do_action( 'woocommerce_subscription_item_switched', $order, $subscription, $item_id, $cart_item['subscription_switch']['item_id'] ); + } + + // If the old subscription has just one item, we can safely update its billing schedule + if ( $is_single_item_subscription ) { + + if ( $is_different_billing_schedule ) { + update_post_meta( $subscription->id, '_billing_period', $cart_item['data']->subscription_period ); + update_post_meta( $subscription->id, '_billing_interval', absint( $cart_item['data']->subscription_period_interval ) ); + } + + $updated_dates = array(); + + if ( '1' == $cart_item['data']->subscription_length || ( 0 != $recurring_cart->end_date && date( 'Y-m-d H:i:s', $cart_item['subscription_switch']['first_payment_timestamp'] ) >= $recurring_cart->end_date ) ) { + $subscription->delete_date( 'next_payment' ); + } else if ( $is_different_payment_date ) { + $updated_dates['next_payment'] = date( 'Y-m-d H:i:s', $cart_item['subscription_switch']['first_payment_timestamp'] ); + } + + if ( $is_different_length ) { + $updated_dates['end'] = $recurring_cart->end_date; + } + + if ( ! empty( $updated_dates ) ) { + $subscription->update_dates( $updated_dates ); + } + } + + // Remove the old item from the subscription but don't delete it completely by changing its line item type to "line_item_switched" + wc_update_order_item( $cart_item['subscription_switch']['item_id'], array( 'order_item_type' => 'line_item_switched' ) ); + + $old_item_name = wcs_get_order_item_name( $existing_item, array( 'attributes' => true ) ); + $new_item_name = wcs_get_cart_item_name( $cart_item, array( 'attributes' => true ) ); + + // translators: 1$: old item name, 2$: new item name when switching + $subscription->add_order_note( sprintf( _x( 'Customer switched from: %1$s to %2$s.', 'used in order notes', 'woocommerce-subscriptions' ), $old_item_name, $new_item_name ) ); + + // Change the shipping + self::update_shipping_methods( $subscription, $recurring_cart ); + + // Finally, change the addresses but only if they've changed + self::maybe_update_subscription_address( $order, $subscription ); + + $subscription->calculate_totals(); + } + } + + // Everything seems to be in order + $wpdb->query( 'COMMIT' ); + + } catch ( Exception $e ) { + // There was an error adding the subscription, roll back and delete pending order for switch + $wpdb->query( 'ROLLBACK' ); + wp_delete_post( $order_id, true ); + throw $e; + } + } + + /** + * Update shipping method on the subscription if the order changed anything + * + * @param WC_Order $order The new order + * @param WC_Subscription $subscription The original subscription + * @param WC_Cart $recurring_cart A recurring cart + */ + public static function update_shipping_methods( $subscription, $recurring_cart ) { + + // First, archive all the shipping methods + foreach ( $subscription->get_shipping_methods() as $shipping_method_id => $shipping_method ) { + wc_update_order_item( $shipping_method_id, array( 'order_item_type' => 'shipping_switched' ) ); + } + + // Then zero the order_shipping total so we have a clean slate to add to + $subscription->order_shipping = 0; + + WC_Subscriptions_Checkout::add_shipping( $subscription, $recurring_cart ); + + // Now update subscription object order_shipping to reflect updated values so it doesn't stay 0 + $subscription->order_shipping = get_post_meta( $subscription->id, '_order_shipping', true ); + } + + /** + * Updates address on the subscription if one of them is changed. + * + * @param WC_Order $order The new order + * @param WC_Subscription $subscription The original subscription + */ + public static function maybe_update_subscription_address( $order, $subscription ) { + + if ( method_exists( $subscription, 'get_address' ) ) { + + $order_billing = $order->get_address( 'billing' ); + $order_shipping = $order->get_address(); + $subscription_billing = $subscription->get_address( 'billing' ); + $subscription_shipping = $subscription->get_address(); + + } else { + + $order_billing = wcs_get_order_address( $order, 'billing' ); + $order_shipping = wcs_get_order_address( $order ); + $subscription_billing = wcs_get_order_address( $subscription, 'billing' ); + $subscription_shipping = wcs_get_order_address( $subscription ); + + } + + $subscription->set_address( array_diff_assoc( $order_billing, $subscription_billing ), 'billing' ); + $subscription->set_address( array_diff_assoc( $order_shipping, $subscription_shipping ), 'shipping' ); + + } + + /** + * If the subscription purchased in an order has since been switched, include a link to the order placed to switch the subscription + * in the "Related Orders" meta box (displayed on the Edit Order screen). + * + * @param WC_Order $order The current order. + * @since 1.4 + */ + public static function switch_order_meta_box_rows( $post ) { + + $subscriptions = array(); + $switched_ids = array(); + $orders = array(); + + // On the subscription page, just show related orders + if ( wcs_is_subscription( $post->ID ) ) { + + // Select the orders which switched item/s from this subscription + $orders = wcs_get_switch_orders_for_subscription( $post->ID ); + + foreach ( $orders as $order ) { + $orders[ $order->id ]->relationship = __( 'Switch Order', 'woocommerce-subscriptions' ); + } + + // Select the subscriptions which had item/s switched to this subscription by its parent order + if ( ! empty( $post->post_parent ) ) { + $switched_ids = get_post_meta( $post->post_parent, '_subscription_switch' ); + } + + // On the Edit Order screen, show any subscriptions with items switched by this order + } else { + $switched_ids = get_post_meta( $post->ID, '_subscription_switch', false ); + } + + foreach ( $switched_ids as $subscription_id ) { + $subscription = wcs_get_subscription( $subscription_id ); + $subscription->relationship = __( 'Switched Subscription', 'woocommerce-subscriptions' ); + $orders[ $subscription_id ] = $subscription; + } + + foreach ( $orders as $order ) { + include( plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'includes/admin/meta-boxes/views/html-related-orders-row.php' ); + } + + } + + /** + * Check if the cart includes any items which are to switch an existing subscription's item. + * + * @return bool|array Returns all the items that are for a switching or false if none of the items in the cart are a switch request. + * @since 2.0 + */ + public static function cart_contains_switches() { + + $subscription_switches = false; + + if ( is_admin() && ( ! defined( 'DOING_AJAX' ) || false == DOING_AJAX ) ) { + return $subscription_switches; + } + + if ( isset( WC()->cart ) ) { + // We use WC()->cart->cart_contents instead of WC()->cart->get_cart() to prevent recursion caused when get_cart_from_session() too early is called ref: https://github.com/woothemes/woocommerce/commit/1f3365f2066b1e9d7e84aca7b1d7e89a6989c213 + foreach ( WC()->cart->cart_contents as $cart_item_key => $cart_item ) { + if ( isset( $cart_item['subscription_switch'] ) ) { + if ( wcs_is_subscription( $cart_item['subscription_switch']['subscription_id'] ) ) { + $subscription_switches[ $cart_item_key ] = $cart_item['subscription_switch']; + } else { + WC()->cart->remove_cart_item( $cart_item_key ); + WC_Subscriptions::add_notice( __( 'Your cart contained an invalid subscription switch request. It has been removed.', 'woocommerce-subscriptions' ), 'error' ); + } + } + } + } + + return $subscription_switches; + } + + /** + * Check if the cart includes any items which are to switch an existing subscription's item. + * + * @param int|object Either a product ID (not variation ID) or product object + * @return bool True if the cart contains a switch fora given product, or false if it does not. + * @since 2.0 + */ + public static function cart_contains_switch_for_product( $product ) { + + $product_id = ( is_object( $product ) ) ? $product->id : $product; + $switch_items = self::cart_contains_switches(); + $switch_product_ids = array(); + + if ( false !== $switch_items ) { + + // Check if there is a switch for this variation product + foreach ( $switch_items as $switch_item_details ) { + + $switch_product = wc_get_product( wcs_get_order_items_product_id( $switch_item_details['item_id'] ) ); + + // If the switch is for a grouped product, we need to check the other products grouped with this one + if ( 0 !== $product->post->post_parent ) { + $switch_product_ids = array_unique( array_merge( $switch_product_ids, wc_get_product( $product->post->post_parent )->get_children() ) ); + } else { + $switch_product_ids[] = $switch_product->id; + } + } + } + + return in_array( $product_id, $switch_product_ids ); + } + + /** + * When a product is added to the cart, check if it is being added to switch a subscription and if so, + * make sure it's valid (i.e. not the same subscription). + * + * @since 1.4 + */ + public static function validate_switch_request( $is_valid, $product_id, $quantity, $variation_id = '' ) { + + $error_message = ''; + + try { + + if ( ! isset( $_GET['switch-subscription'] ) ) { + return $is_valid; + } + + if ( empty( $_GET['_wcsnonce'] ) || ! wp_verify_nonce( $_GET['_wcsnonce'], 'wcs_switch_request' ) ) { + return false; + } + + $subscription = wcs_get_subscription( $_GET['switch-subscription'] ); + $item_id = absint( $_GET['item'] ); + $item = wcs_get_order_item( $item_id, $subscription ); + + // Check if the chosen variation's attributes are different to the existing subscription's attributes (to support switching between a "catch all" variation) + if ( empty( $item ) ) { + + throw new Exception( __( 'We can not find your old subscription item.', 'woocommerce-subscriptions' ) ); + + } else { + + $identical_attributes = true; + + foreach ( $_POST as $key => $value ) { + if ( false !== strpos( $key, 'attribute_' ) && ! empty( $item[ str_replace( 'attribute_', '', $key ) ] ) && $item[ str_replace( 'attribute_', '', $key ) ] != $value ) { + $identical_attributes = false; + break; + } + } + + if ( $product_id == $item['product_id'] && ( empty( $variation_id ) || ( $variation_id == $item['variation_id'] && true == $identical_attributes ) ) && $quantity == $item['qty'] ) { + throw new Exception( __( 'You can not switch to the same subscription.', 'woocommerce-subscriptions' ) ); + } + + // Also remove any existing items in the cart for switching this item (but don't make the switch invalid) + if ( $is_valid ) { + + $existing_switch_items = self::cart_contains_switches(); + + if ( false !== $existing_switch_items ) { + foreach ( $existing_switch_items as $cart_item_key => $switch_item ) { + if ( $switch_item['item_id'] == $item_id ) { + WC()->cart->remove_cart_item( $cart_item_key ); + } + } + } + } + } + } catch ( Exception $e ) { + $error_message = $e->getMessage(); + } + + $error_message = apply_filters( 'woocommerce_subscriptions_switch_error_message', $error_message, $product_id, $quantity, $variation_id, $subscription, $item ); + + if ( ! empty( $error_message ) ) { + wc_add_notice( $error_message, 'error' ); + $is_valid = false; + } + + return apply_filters( 'woocommerce_subscriptions_is_switch_valid', $is_valid, $product_id, $quantity, $variation_id, $subscription, $item ); + } + + /** + * When a subscription switch is added to the cart, store a record of pertinent meta about the switch. + * + * @since 1.4 + */ + public static function set_switch_details_in_cart( $cart_item_data, $product_id, $variation_id ) { + + try { + if ( ! isset( $_GET['switch-subscription'] ) ) { + return $cart_item_data; + } + + $subscription = wcs_get_subscription( $_GET['switch-subscription'] ); + + // Requesting a switch for someone elses subscription + if ( ! current_user_can( 'switch_shop_subscription', $subscription->id ) ) { + WC_Subscriptions::add_notice( __( 'You can not switch this subscription. It appears you do not own the subscription.', 'woocommerce-subscriptions' ), 'error' ); + WC()->cart->empty_cart( true ); + wp_redirect( get_permalink( $subscription['product_id'] ) ); + exit(); + } + + $item = wcs_get_order_item( absint( $_GET['item'] ), $subscription ); + + // Else it's a valid switch + $product = wc_get_product( $item['product_id'] ); + + $child_products = ( 0 !== $product->post->post_parent ) ? wc_get_product( $product->post->post_parent )->get_children() : array(); + + if ( $product_id != $item['product_id'] && ! in_array( $item['product_id'], $child_products ) ) { + return $cart_item_data; + } + + $next_payment_timestamp = $subscription->get_time( 'next_payment' ); + + // If there are no more payments due on the subscription, because we're in the last billing period, we need to use the subscription's expiration date, not next payment date + if ( false == $next_payment_timestamp ) { + $next_payment_timestamp = $subscription->get_time( 'end' ); + } + + $cart_item_data['subscription_switch'] = array( + 'subscription_id' => $subscription->id, + 'item_id' => absint( $_GET['item'] ), + 'next_payment_timestamp' => $next_payment_timestamp, + 'upgraded_or_downgraded' => '', + ); + + return $cart_item_data; + + } catch ( Exception $e ) { + + WC_Subscriptions::add_notice( __( 'There was an error locating the switch details.', 'woocommerce-subscriptions' ), 'error' ); + WC()->cart->empty_cart( true ); + wp_redirect( get_permalink( wc_get_page_id( 'cart' ) ) ); + exit(); + } + } + + /** + * Get the recurring amounts values from the session + * + * @since 1.4 + */ + public static function get_cart_from_session( $cart_item_data, $cart_item, $key ) { + if ( isset( $cart_item['subscription_switch'] ) ) { + $cart_item_data['subscription_switch'] = $cart_item['subscription_switch']; + } + + return $cart_item_data; + } + + /** + * Make sure the sign-up fee on a subscription line item takes into account sign-up fees paid for switching. + * + * @param WC_Subscription $subscription + * @return array $cart_item Details of an item in WC_Cart for a switch + * @since 2.0 + */ + public static function subscription_items_sign_up_fee( $sign_up_fee, $line_item, $subscription ) { + + // This item has never been switched, no need to add anything + if ( ! isset( $line_item['switched_subscription_item_id'] ) ) { + return $sign_up_fee; + } + + // First add any sign-up fees for previously switched items + $switched_line_items = $subscription->get_items( 'line_item_switched' ); + + foreach ( $switched_line_items as $switched_line_item_id => $switched_line_item ) { + if ( $line_item['switched_subscription_item_id'] == $switched_line_item_id ) { + $sign_up_fee += $subscription->get_items_sign_up_fee( $switched_line_item ); // Recursion: get the sign up fee for this item's old item and the sign up fee for that item's old item and the sign up fee for that item's old item and the sign up fee for that item's old item ... + break; // Each item can only be switched once + } + } + + // Now add any sign-up fees paid in switch orders + foreach ( wcs_get_switch_orders_for_subscription( $subscription->id ) as $order ) { + foreach ( $order->get_items() as $order_item_id => $order_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == wcs_get_canonical_product_id( $order_item ) ) { + + // We only want to add the amount of the line total which was for a prorated sign-up fee, not the amount for a prorated recurring amount + if ( isset( $order_item['switched_subscription_sign_up_fee_prorated'] ) ) { + if ( $order_item['switched_subscription_sign_up_fee_prorated'] > 0 ) { + $sign_up_proportion = $order_item['switched_subscription_sign_up_fee_prorated'] / ( $order_item['switched_subscription_price_prorated'] + $order_item['switched_subscription_sign_up_fee_prorated'] ); + } else { + $sign_up_proportion = 0; + } + } else { + $sign_up_proportion = 1; + } + + $sign_up_fee += round( $order_item['line_total'] * $sign_up_proportion, 2 ); + } + } + } + + return $sign_up_fee; + } + + /** + * Set the subscription prices to be used in calculating totals by @see WC_Subscriptions_Cart::calculate_subscription_totals() + * + * @since 2.0 + */ + public static function calculate_prorated_totals( $cart ) { + + if ( false === self::cart_contains_switches() ) { + return; + } + + // Maybe charge an initial amount to account for upgrading from a cheaper subscription + $apportion_recurring_price = get_option( WC_Subscriptions_Admin::$option_prefix . '_apportion_recurring_price', 'no' ); + $apportion_sign_up_fee = get_option( WC_Subscriptions_Admin::$option_prefix . '_apportion_sign_up_fee', 'no' ); + $apportion_length = get_option( WC_Subscriptions_Admin::$option_prefix . '_apportion_length', 'no' ); + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + + if ( ! isset( $cart_item['subscription_switch']['subscription_id'] ) ) { + continue; + } + + $subscription = wcs_get_subscription( $cart_item['subscription_switch']['subscription_id'] ); + $existing_item = wcs_get_order_item( $cart_item['subscription_switch']['item_id'], $subscription ); + + if ( empty( $existing_item ) ) { + WC()->cart->remove_cart_item( $cart_item_key ); + continue; + } + + $item_data = $cart_item['data']; + $product_id = wcs_get_canonical_product_id( $cart_item ); + $product = wc_get_product( $product_id ); + $is_virtual_product = $product->is_virtual(); + + // Set when the first payment and end date for the new subscription should occur + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['first_payment_timestamp'] = $cart_item['subscription_switch']['next_payment_timestamp']; + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['end_timestamp'] = $end_timestamp = strtotime( WC_Subscriptions_Product::get_expiration_date( $product_id, $subscription->get_date( 'last_payment' ) ) ); + + // Add any extra sign up fees required to switch to the new subscription + if ( 'yes' == $apportion_sign_up_fee ) { + + // Because product add-ons etc. don't apply to sign-up fees, it's safe to use the product's sign-up fee value rather than the cart item's + $sign_up_fee_due = $product->subscription_sign_up_fee; + $sign_up_fee_paid = $subscription->get_items_sign_up_fee( $existing_item, 'inclusive_of_tax' ); + + // Make sure total prorated sign-up fee is prorated across total amount of sign-up fee so that customer doesn't get extra discounts + if ( $cart_item['quantity'] > $existing_item['qty'] ) { + $sign_up_fee_paid = ( $sign_up_fee_paid * $existing_item['qty'] ) / $cart_item['quantity']; + } + + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee = max( $sign_up_fee_due - $sign_up_fee_paid, 0 ); + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee_prorated = WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee; + + } elseif ( 'no' == $apportion_sign_up_fee ) { // $0 the initial sign-up fee + + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee = 0; + + } + + // Get the current subscription's last payment date + $last_payment_timestamp = $subscription->get_time( 'last_payment' ); + $days_since_last_payment = floor( ( gmdate( 'U' ) - $last_payment_timestamp ) / ( 60 * 60 * 24 ) ); + + // Get the current subscription's next payment date + $next_payment_timestamp = $cart_item['subscription_switch']['next_payment_timestamp']; + $days_until_next_payment = ceil( ( $next_payment_timestamp - gmdate( 'U' ) ) / ( 60 * 60 * 24 ) ); + + // If the subscription contains a synced product and the next payment is actually the first payment, determine the days in the "old" cycle from the subscription object + if ( WC_Subscriptions_Synchroniser::subscription_contains_synced_product( $subscription->id ) && WC_Subscriptions_Synchroniser::calculate_first_payment_date( $product, 'timestamp', $subscription->get_date( 'start' ) ) == $next_payment_timestamp ) { + $days_in_old_cycle = wcs_get_days_in_cycle( $subscription->billing_period, $subscription->billing_interval ); + } else { + // Find the number of days between the two + $days_in_old_cycle = $days_until_next_payment + $days_since_last_payment; + } + + // Find the actual recurring amount charged for the old subscription (we need to use the '_recurring_line_total' meta here rather than '_subscription_recurring_amount' because we want the recurring amount to include extra from extensions, like Product Add-ons etc.) + $old_recurring_total = $existing_item['line_total']; + + if ( 'yes' == $subscription->prices_include_tax || true === $subscription->prices_include_tax ) { // WC_Abstract_Order::$prices_include_tax can be set to true in __construct() or to 'yes' in populate() + $old_recurring_total += $existing_item['line_tax']; + } + + // Find the $price per day for the old subscription's recurring total + $old_price_per_day = $old_recurring_total / $days_in_old_cycle; + + // Find the price per day for the new subscription's recurring total + // If the subscription uses the same billing interval & cycle as the old subscription, + if ( $item_data->subscription_period == $subscription->billing_period && $item_data->subscription_period_interval == $subscription->billing_interval ) { + + $days_in_new_cycle = $days_in_old_cycle; // Use $days_in_old_cycle to make sure they're consistent + + } else { + + // We need to figure out the price per day for the new subscription based on its billing schedule + $days_in_new_cycle = wcs_get_days_in_cycle( $item_data->subscription_period, $item_data->subscription_period_interval ); + } + + // We need to use the cart items price to ensure we include extras added by extensions like Product Add-ons + $new_price_per_day = ( $item_data->price * $cart_item['quantity'] ) / $days_in_new_cycle; + + if ( $old_price_per_day < $new_price_per_day ) { + + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['upgraded_or_downgraded'] = 'upgraded'; + + } elseif ( $old_price_per_day > $new_price_per_day && $new_price_per_day >= 0 ) { + + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['upgraded_or_downgraded'] = 'downgraded'; + + } + + // Now lets see if we should add a prorated amount to the sign-up fee (for upgrades) or extend the next payment date (for downgrades) + if ( in_array( $apportion_recurring_price, array( 'yes', 'yes-upgrade' ) ) || ( in_array( $apportion_recurring_price, array( 'virtual', 'virtual-upgrade' ) ) && $is_virtual_product ) ) { + + // If the customer is upgrading, we may need to add a gap payment to the sign-up fee or to reduce the pre-paid period (or both) + if ( $old_price_per_day < $new_price_per_day ) { + + // The new subscription may be more expensive, but it's also on a shorter billing cycle, so reduce the next pre-paid term + if ( $days_in_old_cycle > $days_in_new_cycle ) { + + // Find out how many days at the new price per day the customer would receive for the total amount already paid + // (e.g. if the customer paid $10 / month previously, and was switching to a $5 / week subscription, she has pre-paid 14 days at the new price) + $pre_paid_days = 0; + do { + $pre_paid_days++; + $new_total_paid = $pre_paid_days * $new_price_per_day; + } while ( $new_total_paid < $old_recurring_total ); + + // If the total amount the customer has paid entitles her to more days at the new price than she has received, there is no gap payment, just shorten the pre-paid term the appropriate number of days + if ( $days_since_last_payment < $pre_paid_days ) { + + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['first_payment_timestamp'] = $last_payment_timestamp + ( $pre_paid_days * 60 * 60 * 24 ); + + // If the total amount the customer has paid entitles her to the same or less days at the new price then start the new subscription from today + } else { + + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['first_payment_timestamp'] = 0; + + } + } else { + + $extra_to_pay = $days_until_next_payment * ( $new_price_per_day - $old_price_per_day ); + + // when calculating a subscription with one length (no more next payment date and the end date may have been pushed back) we need to pay for those extra days at the new price per day between the old next payment date and new end date + if ( 1 == $item_data->subscription_length ) { + $days_to_new_end = floor( ( $end_timestamp - $next_payment_timestamp ) / ( 60 * 60 * 24 ) ); + + if ( $days_to_new_end > 0 ) { + $extra_to_pay += $days_to_new_end * $new_price_per_day; + } + } + + // We need to find the per item extra to pay so we can set it as the sign-up fee (WC will then multiply it by the quantity) + $extra_to_pay = $extra_to_pay / $cart_item['quantity']; + + // Keep a record of the two separate amounts so we store these and calculate future switch amounts correctly + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee_prorated = WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee; + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_price_prorated = round( $extra_to_pay, 2 ); + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_sign_up_fee += round( $extra_to_pay, 2 ); + + } + + // If the customer is downgrading, set the next payment date and maybe extend it if downgrades are prorated + } elseif ( $old_price_per_day > $new_price_per_day && $new_price_per_day > 0 ) { + + $old_total_paid = $old_price_per_day * $days_until_next_payment; + $new_total_paid = $new_price_per_day; + + // if downgrades are apportioned, extend the next payment date for n more days + if ( in_array( $apportion_recurring_price, array( 'virtual', 'yes' ) ) ) { + + // Find how many more days at the new lower price it takes to exceed the amount already paid + for ( $days_to_add = 0; $new_total_paid <= $old_total_paid; $days_to_add++ ) { + $new_total_paid = $days_to_add * $new_price_per_day; + } + + $days_to_add -= $days_until_next_payment; + } else { + $days_to_add = 0; + } + + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['first_payment_timestamp'] = $next_payment_timestamp + ( $days_to_add * 60 * 60 * 24 ); + + } // The old price per day == the new price per day, no need to change anything + + if ( WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['first_payment_timestamp'] != $cart_item['subscription_switch']['next_payment_timestamp'] ) { + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['recurring_payment_prorated'] = true; + } + } + + // Finally, if we need to make sure the initial total doesn't include any recurring amount, we can by spoofing a free trial + if ( 0 != WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['first_payment_timestamp'] ) { + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_trial_length = 1; + } + + if ( 'yes' == $apportion_length || ( 'virtual' == $apportion_length && $is_virtual_product ) ) { + + $base_length = WC_Subscriptions_Product::get_length( $product_id ); + $completed_payments = $subscription->get_completed_payment_count(); + $length_remaining = $base_length - $completed_payments; + + // Default to the base length if more payments have already been made than this subscription requires + if ( $length_remaining <= 0 ) { + $length_remaining = $base_length; + } + + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_length = $length_remaining; + } + } + } + + /** + * Make sure when displaying the first payment date for a switched subscription, the date takes into + * account the switch (i.e. prepaid days and possibly a downgrade). + * + * @since 2.0 + */ + public static function recurring_cart_next_payment_date( $first_renewal_date, $cart ) { + + foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { + if ( isset( $cart_item['subscription_switch']['first_payment_timestamp'] ) ) { + $first_renewal_date = ( '1' != $cart_item['data']->subscription_length ) ? date( 'Y-m-d H:i:s', $cart_item['subscription_switch']['first_payment_timestamp'] ) : 0; + } + } + + return $first_renewal_date; + } + + /** + * Make sure the end date of the switched subscription starts after already paid term + * + * @since 2.0 + */ + public static function recurring_cart_end_date( $end_date, $cart, $product ) { + + if ( 0 !== $end_date ) { + foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { + + if ( isset( $cart_item['subscription_switch']['subscription_id'] ) && isset( $cart_item['data'] ) && $product == $cart_item['data'] ) { + $next_payment_time = isset( $cart_item['subscription_switch']['first_payment_timestamp'] ) ? $cart_item['subscription_switch']['first_payment_timestamp'] : 0; + $end_timestamp = WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['end_timestamp']; + + // if the subscription is length 1 and prorated, we want to use the prorated the next payment date as the end date + if ( 1 == $cart_item['data']->subscription_length && 0 !== $next_payment_time && isset( $cart_item['subscription_switch']['recurring_payment_prorated'] ) ) { + $end_date = date( 'Y-m-d H:i:s', $next_payment_time ); + + // if the subscription is more than 1 (and not 0) and we have a next payment date (prorated or not) we want to calculate the new end date from that + } elseif ( 0 !== $next_payment_time && $cart_item['data']->subscription_length > 1 ) { + // remove trial period on the switched subscription when calculating the new end date + $trial_length = $cart_item['data']->subscription_trial_length; + $cart_item['data']->subscription_trial_length = 0; + + $end_date = WC_Subscriptions_Product::get_expiration_date( $cart_item['data'], date( 'Y-m-d H:i:s', $next_payment_time ) ); + + // add back the trial length if it has been spoofed + $cart_item['data']->subscription_trial_length = $trial_length; + + // elseif fallback to using the end date set on the cart item + } elseif ( ! empty( $end_timestamp ) ) { + $end_date = date( 'Y-m-d H:i:s', $end_timestamp ); + } + + break; + } + } + } + return $end_date; + } + + /** + * Make sure that a switch items cart key is based on it's first renewal date, not the date calculated for the product. + * + * @since 2.0 + */ + public static function get_recurring_cart_key( $cart_key, $cart_item ) { + + if ( isset( $cart_item['subscription_switch']['first_payment_timestamp'] ) ) { + remove_filter( 'woocommerce_subscriptions_recurring_cart_key', __METHOD__, 10, 2 ); + $cart_key = WC_Subscriptions_Cart::get_recurring_cart_key( $cart_item, $cart_item['subscription_switch']['first_payment_timestamp'] ); + add_filter( 'woocommerce_subscriptions_recurring_cart_key', __METHOD__, 10, 2 ); + } + + return $cart_key; + } + + /** + * If the current request is to switch subscriptions, don't show a product's free trial period (because there is no + * free trial for subscription switches) and also if the length is being prorateed, don't display the length until + * checkout. + * + * @since 1.4 + */ + public static function customise_product_string_inclusions( $inclusions, $product ) { + + if ( isset( $_GET['switch-subscription'] ) || self::cart_contains_switch_for_product( $product ) ) { + + $inclusions['trial_length'] = false; + + $apportion_length = get_option( WC_Subscriptions_Admin::$option_prefix . '_apportion_length', 'no' ); + $apportion_sign_up_fee = get_option( WC_Subscriptions_Admin::$option_prefix . '_apportion_sign_up_fee', 'no' ); + + if ( 'yes' == $apportion_length || ( 'virtual' == $apportion_length && $product->is_virtual() ) ) { + $inclusions['subscription_length'] = false; + } + + if ( 'no' === $apportion_sign_up_fee ) { + $inclusions['sign_up_fee'] = false; + } + } + + return $inclusions; + } + + /** + * If a product is being marked as not purchasable because it is limited and the customer has a subscription, + * but the current request is to switch the subscription, then mark it as purchasable. + * + * @since 1.4.4 + * @return bool + */ + public static function is_purchasable( $is_purchasable, $product ) { + + $product_key = ! empty( $product->variation_id ) ? $product->variation_id : $product->id; + + if ( ! isset( self::$is_purchasable_cache[ $product_key ] ) ) { + + if ( false === $is_purchasable && wcs_is_product_switchable_type( $product ) && WC_Subscriptions_Product::is_subscription( $product->id ) && 'no' != $product->limit_subscriptions && is_user_logged_in() && wcs_user_has_subscription( 0, $product->id, $product->limit_subscriptions ) ) { + + // Adding to cart from the product page + if ( isset( $_GET['switch-subscription'] ) ) { + + $is_purchasable = true; + + // Validating when restring cart from session + } elseif ( self::cart_contains_switches() ) { + + $is_purchasable = true; + + // Restoring cart from session, so need to check the cart in the session (self::cart_contains_subscription_switch() only checks the cart) + } elseif ( isset( WC()->session->cart ) ) { + + foreach ( WC()->session->cart as $cart_item_key => $cart_item ) { + if ( $product->id == $cart_item['product_id'] && isset( $cart_item['subscription_switch'] ) ) { + $is_purchasable = true; + break; + } + } + } + } + + self::$is_purchasable_cache[ $product_key ] = $is_purchasable; + } + + return self::$is_purchasable_cache[ $product_key ]; + } + + /** + * Automatically set a switch order's status to complete (even if the items require shipping because + * the order is simply a record of the switch and not indicative of an item needing to be shipped) + * + * @since 1.5 + */ + public static function subscription_switch_autocomplete( $new_order_status, $order_id ) { + + if ( 'processing' == $new_order_status && wcs_order_contains_switch( $order_id ) ) { + $order = wc_get_order( $order_id ); + $all_switched = true; + + if ( 0 == $order->get_total() ) { + + foreach ( $order->get_items() as $item ) { + if ( ! isset( $item['switched_subscription_price_prorated'] ) ) { + $all_switched = false; + break; + } + } + + if ( $all_switched || 1 == count( $order->get_items() ) ) { + $new_order_status = 'completed'; + } + } + } + + return $new_order_status; + } + + /** + * Do not carry over switch related meta data to renewal orders. + * + * @since 1.5.4 + */ + public static function remove_renewal_order_meta_query( $order_meta_query ) { + + $order_meta_query .= " AND `meta_key` NOT IN ('_subscription_switch')"; + + return $order_meta_query; + } + + /** + * Make the switch process persist even if the subscription product has Product Addons that need to be set. + * + * @since 1.5.6 + */ + public static function addons_add_to_cart_url( $add_to_cart_url ) { + + if ( isset( $_GET['switch-subscription'] ) && false === strpos( $add_to_cart_url, 'switch-subscription' ) ) { + $add_to_cart_url = self::add_switch_query_args( $_GET['switch-subscription'], $_GET['item'], $add_to_cart_url ); + } + + return $add_to_cart_url; + } + + /** + * Checks if a product can be switched based on it's type and the types which can be switched + * + * @since 1.5.21 + */ + public static function is_product_of_switchable_type( $product ) { + + _deprecated_function( __METHOD__, '2.0.7', 'wcs_is_product_switchable_type' ); + + $allow_switching = false; + $switch_setting = get_option( WC_Subscriptions_Admin::$option_prefix . '_allow_switching', 'no' ); + + // does the current switch setting allow switching for variable or variable_grouped + if ( 'variable_grouped' == $switch_setting || ( $product->is_type( array( 'variable-subscription', 'subscription_variation' ) ) && 'variable' == $switch_setting ) || ( 'grouped' == $switch_setting && ( $product->is_type( 'grouped' ) || 0 !== $product->post->post_parent ) ) ) { + $allow_switching = true; + } + + return $allow_switching; + } + + /** + * Check if a given subscription item was for upgrading/downgrading an existing item. + * + * @since 2.0 + */ + protected static function is_item_switched( $item ) { + + $is_item_switched = isset( $item['switched'] ) ? true : false; + + return $is_item_switched; + } + + /** + * Do not display switch related order item meta keys unless Subscriptions is in debug mode. + * + * @since 2.0 + */ + public static function hidden_order_itemmeta( $hidden_meta_keys ) { + + if ( ! defined( 'WCS_DEBUG' ) || true !== WCS_DEBUG ) { + $hidden_meta_keys = array_merge( $hidden_meta_keys, array( + '_switched_subscription_item_id', + '_switched_subscription_new_item_id', + '_switched_subscription_sign_up_fee_prorated', + '_switched_subscription_price_prorated', + ) + ); + } + + return $hidden_meta_keys; + } + + /** + * Stop the switch link from printing on email templates + * + * @since 2.0 + */ + public static function remove_print_switch_link() { + remove_filter( 'woocommerce_order_item_meta_end', __CLASS__ . '::print_switch_link', 10 ); + } + + /** + * Add the print switch link filter back after the subscription items table has been created in email template + * + * @since 2.0 + */ + public static function add_print_switch_link( $table_content ) { + add_filter( 'woocommerce_order_item_meta_end', __CLASS__ . '::print_switch_link', 10, 3 ); + return $table_content; + } + + /** + * Filter the WC_Subscription::get_related_orders() method to include switch orders. + * + * @since 2.0 + */ + public static function add_related_orders( $related_orders, $subscription, $return_fields, $order_type ) { + + if ( in_array( $order_type, array( 'all', 'switch' ) ) ) { + + $switch_orders = wcs_get_switch_orders_for_subscription( $subscription->id ); + + if ( 'all' == $return_fields ) { + $related_orders += $switch_orders; + } else { + foreach ( $switch_orders as $order_id => $order ) { + $related_orders[ $order_id ] = $order_id; + } + } + + // This will change the ordering to be by ID instead of the default of date + krsort( $related_orders ); + } + + return $related_orders; + } + + /** + * Add the cart item upgrade/downgrade/crossgrade direction for display + * + * @since 2.0 + */ + public static function add_cart_item_switch_direction( $product_subtotal, $cart_item, $cart_item_key ) { + + if ( ! empty( $cart_item['subscription_switch'] ) ) { + + switch ( $cart_item['subscription_switch']['upgraded_or_downgraded'] ) { + case 'downgraded' : + $direction = _x( 'Downgrade', 'a switch order', 'woocommerce-subscriptions' ); + break; + case 'upgraded' : + $direction = _x( 'Upgrade', 'a switch order', 'woocommerce-subscriptions' ); + break; + default : + $direction = _x( 'Crossgrade', 'a switch order', 'woocommerce-subscriptions' ); + break; + } + + // translators: %1: product subtotal, %2: HTML span tag, %3: direction (upgrade, downgrade, crossgrade), %4: closing HTML span tag + $product_subtotal = sprintf( _x( '%1$s %2$s(%3$s)%4$s', 'product subtotal string', 'woocommerce-subscriptions' ), $product_subtotal, '', $direction, '' ); + + } + + return $product_subtotal; + } + + /** + * Creates a 2.0 updated version of the "subscriptions_switched" callback for developers to hook onto. + * + * The subscription passed to the new `woocommerce_subscriptions_switched_item` callback is strictly the subscription + * to which the `$new_order_item` belongs to; this may be a new or the original subscription. + * + * @since 2.0.5 + * @param WC_Order $order + */ + public static function maybe_add_switched_callback( $order ) { + if ( wcs_order_contains_switch( $order ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order ); + + foreach ( $subscriptions as $subscription ) { + foreach ( $subscription->get_items() as $new_order_item ) { + if ( isset( $new_order_item['switched_subscription_item_id'] ) ) { + + $product_id = wcs_get_canonical_product_id( $new_order_item ); + // we need to check if the switch order contains the line item that has just been switched so that we don't call the hook on items that were previously switched in another order + foreach ( $order->get_items() as $order_item ) { + if ( wcs_get_canonical_product_id( $order_item ) == $product_id ) { + do_action( 'woocommerce_subscriptions_switched_item', $subscription, $new_order_item, WC_Subscriptions_Order::get_item_by_id( $new_order_item['switched_subscription_item_id'] ) ); + break; + } + } + } + } + } + } + } + + /** + * Revoke download permissions granted on the old switch item. + * + * @since 2.0.9 + * @param WC_Subscription $subscription + * @param array $new_item + * @param array $old_item + */ + public static function remove_download_permissions_after_switch( $subscription, $new_item, $old_item ) { + + if ( ! is_object( $subscription ) ) { + $subscription = wcs_get_subscription( $subscription ); + } + + $product_id = wcs_get_canonical_product_id( $old_item ); + WCS_Download_Handler::revoke_downloadable_file_permission( $product_id, $subscription->id, $subscription->customer_user ); + } + + /** + * If we are switching a $0 / period subscription to a non-zero $ / period subscription, and the existing + * subscription is using manual renewals but manual renewals are not forced on the site, we need to set a + * flag to force WooCommerce to require payment so that we can switch the subscription to automatic renewals + * because it was probably only set to manual because it was $0. + * + * We need to determine this here instead of on the 'woocommerce_cart_needs_payment' because when payment is being + * processed, we will have changed the associated subscription data already, so we can't check that subscription's + * values anymore. We determine it here, then ue the 'force_payment' flag on 'woocommerce_cart_needs_payment' to + * require payment. + * + * @param int $total + * @since 2.0.16 + */ + public static function set_force_payment_flag_in_cart( $total ) { + + if ( $total > 0 || 'yes' == get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) || false === self::cart_contains_switches() ) { + return $total; + } + + $old_recurring_total = 0; + $new_recurring_total = 0; + $has_future_payments = false; + + // Check that the new subscriptions are not for $0 recurring and there is a future payment required + foreach ( WC()->cart->recurring_carts as $cart ) { + + $new_recurring_total += $cart->total; + + if ( $cart->next_payment_date > 0 ) { + $has_future_payments = true; + } + } + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + + if ( ! isset( $cart_item['subscription_switch']['subscription_id'] ) ) { + continue; + } + + $subscription = wcs_get_subscription( $cart_item['subscription_switch']['subscription_id'] ); + + // Check that the existing subscriptions are for $0 recurring + $old_recurring_total = $subscription->get_total(); + + if ( 0 == $old_recurring_total && $new_recurring_total > 0 && true === $has_future_payments && $subscription->is_manual() ) { + WC()->cart->cart_contents[ $cart_item_key ]['subscription_switch']['force_payment'] = true; + } + } + + return $total; + } + + /** + * Require payment when switching from a $0 / period subscription to a non-zero subscription to process + * automatic payments for future renewals, as indicated by the 'force_payment' flag on the switch, set in + * @see self::set_force_payment_flag_in_cart(). + * + * @param bool $needs_payment + * @param object $cart + * @since 2.0.16 + */ + public static function cart_needs_payment( $needs_payment, $cart ) { + + if ( false === $needs_payment && 0 == $cart->total && false !== ( $switch_items = self::cart_contains_switches() ) ) { + + foreach ( $switch_items as $switch_item ) { + if ( isset( $switch_item['force_payment'] ) && true === $switch_item['force_payment'] ) { + $needs_payment = true; + break; + } + } + } + + return $needs_payment; + } + + /** + * Once payment is processed on a switch from a $0 / period subscription to a non-zero $ / period subscription, if + * payment was completed with a payment method which supports automatic payments, update the payment on the subscription + * and the manual renewals flag so that future renewals are processed automatically. + * + * @param array $payment_processing_result + * @param int $order_id + * @since 2.0.16 + */ + public static function maybe_set_payment_method( $payment_processing_result, $order_id ) { + + // Only update the payment method the order contains a switch, and payment was processed (i.e. a paid date has been set) not just setup for processing, which is the case with PayPal Standard (which is handled by WCS_PayPal_Standard_Switcher) + if ( wcs_order_contains_switch( $order_id ) && false != get_post_meta( $order_id, '_paid_date', true ) ) { + + $order = wc_get_order( $order_id ); + + foreach ( wcs_get_subscriptions_for_switch_order( $order_id ) as $subscription ) { + + if ( false === $subscription->is_manual() ) { + continue; + } + + if ( $subscription->payment_method !== $order->payment_method ) { + + // Set the new payment method on the subscription + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + $payment_method = isset( $available_gateways[ $order->payment_method ] ) ? $available_gateways[ $order->payment_method ] : false; + + if ( $payment_method && $payment_method->supports( 'subscriptions' ) ) { + $subscription->set_payment_method( $payment_method ); + $subscription->update_manual( false ); + } + } + } + } + + return $payment_processing_result; + } + + /** Deprecated Methods **/ + + /** + * Don't allow switched subscriptions to be cancelled. + * + * @param bool $subscription_can_be_changed + * @param array $subscription A subscription of the form created by @see WC_Subscriptions_Manager::get_subscription() + * @since 1.4 + * @deprecated 2.0 + */ + public static function can_subscription_be_cancelled( $subscription_can_be_changed, $subscription ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( 'switched' == $subscription['status'] ) { + $subscription_can_be_changed = false; + } + + return $subscription_can_be_changed; + } + + /** + * Adds a "Switch" button to the "My Subscriptions" table for those subscriptions can be upgraded/downgraded. + * + * @param array $all_actions The $subscription_key => $actions array with all actions that will be displayed for a subscription on the "My Subscriptions" table + * @param array $subscriptions All of a given users subscriptions that will be displayed on the "My Subscriptions" table + * @since 1.4 + * @deprecated 2.0 + */ + public static function add_switch_button( $all_actions, $subscriptions ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::print_switch_button( $subscription, $item )' ); + + $user_id = get_current_user_id(); + + foreach ( $all_actions as $subscription_key => $actions ) { + + if ( WC_Subscriptions_Manager::can_subscription_be_changed_to( 'new-subscription', $subscription_key, $user_id ) ) { + $all_actions[ $subscription_key ] = array( + 'switch' => array( + 'url' => self::get_switch_link( $subscription_key ), + 'name' => get_option( WC_Subscriptions_Admin::$option_prefix . '_switch_button_text', __( 'Upgrade or Downgrade', 'woocommerce-subscriptions' ) ), + ), + ) + $all_actions[ $subscription_key ]; + } + } + + return $all_actions; + } + + /** + * The link for switching a subscription - the product page for variable subscriptions, or grouped product page for grouped subscriptions. + * + * @param string $subscription_key A subscription key of the form created by @see self::get_subscription_key() + * @since 1.4 + * @deprecated 2.0 + */ + public static function get_switch_link( $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::get_switch_url( $item_id, $item, $subscription )' ); + } + + /** + * Add a 'new-subscription' handler to the WC_Subscriptions_Manager::can_subscription_be_changed_to() function. + * + * For the subscription to be switchable, switching must be enabled, and the subscription must: + * - be active or on-hold + * - be a variable subscription or part of a grouped product (at the time the check is made, not at the time the subscription was purchased) + * - be using manual renewals or use a payment method which supports cancellation + * + * @param bool $subscription_can_be_changed Flag of whether the subscription can be changed to + * @param string $new_status_or_meta The status or meta data you want to change the subscription to. Can be 'active', 'on-hold', 'cancelled', 'expired', 'trash', 'deleted', 'failed', 'AMQPChannel to the 'woocommerce_can_subscription_be_changed_to' filter. + * @param object $args Set of values used in @see WC_Subscriptions_Manager::can_subscription_be_changed_to() for determining if a subscription can be changed + * @since 1.4 + * @deprecated 2.0 + */ + public static function can_subscription_be_changed_to( $subscription_can_be_changed, $new_status_or_meta, $args ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::can_item_be_switched( $item, $subscription )' ); + return false; + } + + /** + * Check if the cart includes a request to switch a subscription. + * + * @return bool Returns true if any item in the cart is a subscription switch request, otherwise, false. + * @since 1.4 + * @deprecated 2.0 + */ + public static function cart_contains_subscription_switch() { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::cart_contains_switches()' ); + + $cart_contains_subscription_switch = self::cart_contains_switches(); + + // For backward compatiblity, only send the first switch item, not all of them + if ( false !== $cart_contains_subscription_switch ) { + $cart_contains_subscription_switch = array_pop( $cart_contains_subscription_switch ); + } + + return $cart_contains_subscription_switch; + } + + /** + * Previously, we used a trial period to make sure totals are calculated correctly (i.e. order total does not include any recurring + * amounts) but we didn't want switched subscriptions to actually have a trial period, so reset the value on the order after checkout. + * + * This is all redundant now that trial period isn't stored on a subscription item. The first payment date will be used instead. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function fix_order_item_meta( $item_id, $values ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Add the next payment date to the end of the subscription to clarify when the new rate will be charged + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function customise_subscription_price_string( $subscription_string ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Never display the trial period for a subscription switch (we're only setting it to calculate correct totals) + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function customise_cart_subscription_string_details( $subscription_details ) { + _deprecated_function( __METHOD__, '2.0' ); + return $subscription_details; + } + + /** + * Make sure when calculating the first payment date for a switched subscription, the date takes into + * account the switch (i.e. prepaid days and possibly a downgrade). + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function calculate_first_payment_date( $next_payment_date, $order, $product_id, $type ) { + _deprecated_function( __METHOD__, '2.0' ); + return self::get_first_payment_date( $next_payment_date, WC_Subscriptions_Manager::get_subscription_key( $order->id, $product_id ), $order->user_id, $type ); + } + + /** + * Make sure anything requesting the first payment date for a switched subscription receives a date which + * takes into account the switch (i.e. prepaid days and possibly a downgrade). + * + * This is necessary as the self::calculate_first_payment_date() is not called when the subscription is active + * (which it isn't until the first payment is completed and the subscription is activated). + * + * @deprecated 2.0 + */ + public static function get_first_payment_date( $next_payment_date, $subscription_key, $user_id, $type ) { + _deprecated_function( __METHOD__, '2.0' ); + + $subscription = wcs_get_subscription_from_key( $subscription_key ); + + if ( $subscription->has_status( 'active' ) && ! empty( $subscription->order ) && wcs_order_contains_switch( $subscription->order->id ) && 1 >= $subscription->get_completed_payment_count() ) { + + $first_payment_timestamp = get_post_meta( $subscription->order->id, '_switched_subscription_first_payment_timestamp', true ); + + if ( 0 != $first_payment_timestamp ) { + $next_payment_date = ( 'mysql' == $type ) ? date( 'Y-m-d H:i:s', $first_payment_timestamp ) : $first_payment_timestamp; + } + } + + return $next_payment_date; + } + + /** + * Add an i18n'ified string for the "switched" subscription status. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function add_switched_status_string( $status_string ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( 'switched' === strtolower( $status_string ) ) { + $status_string = _x( 'Switched', 'Subscription status', 'woocommerce-subscriptions' ); + } + + return $status_string; + } + + /** + * Set the subscription prices to be used in calculating totals by @see WC_Subscriptions_Cart::calculate_subscription_totals() + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function maybe_set_apporitioned_totals( $total ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::calculate_prorated_totals()' ); + return $total; + } + + /** + * If the subscription purchased in an order has since been switched, include a link to the order placed to switch the subscription + * in the "Related Orders" meta box (displayed on the Edit Order screen). + * + * @param WC_Order $order The current order. + * @since 1.4 + * @deprecated 2.0 + */ + public static function switch_order_meta_box_section( $order ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * After payment is completed on an order for switching a subscription, complete the switch. + * + * @param WC_Order|int $order A WC_Order object or ID of a WC_Order order. + * @since 1.4 + * @deprecated 2.0 + */ + public static function maybe_complete_switch( $order_id ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Check if a given order was created to switch a subscription. + * + * @param WC_Order $order An order to check. + * @return bool Returns true if the order switched a subscription, otherwise, false. + * @since 1.4 + */ + public static function order_contains_subscription_switch( $order_id ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_order_contains_switch( $order_id )' ); + return wcs_order_contains_switch( $order_id ); + } +} +WC_Subscriptions_Switcher::init(); diff --git a/includes/class-wc-subscriptions-synchroniser.php b/includes/class-wc-subscriptions-synchroniser.php new file mode 100644 index 0000000..b56b7db --- /dev/null +++ b/includes/class-wc-subscriptions-synchroniser.php @@ -0,0 +1,1355 @@ +weekday in some places + protected static $weekdays = array( + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + 7 => 'Sunday', + ); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * @since 1.5 + */ + public static function init() { + + self::$setting_id = WC_Subscriptions_Admin::$option_prefix . '_sync_payments'; + self::$setting_id_proration = WC_Subscriptions_Admin::$option_prefix . '_prorate_synced_payments'; + + self::$sync_field_label = __( 'Synchronise Renewals', 'woocommerce-subscriptions' ); + self::$sync_description = __( 'Align the payment date for all customers who purchase this subscription to a specific day of the week or month.', 'woocommerce-subscriptions' ); + // translators: placeholder is a year (e.g. "2016") + self::$sync_description_year = sprintf( _x( 'Align the payment date for this subscription to a specific day of the year. If the date has already taken place this year, the first payment will be processed in %s. Set the day to 0 to disable payment syncing for this product.', 'used in subscription product edit screen', 'woocommerce-subscriptions' ), date( 'Y', strtotime( '+1 year' ) ) ); + + // Add the settings to control whether syncing is enabled and how it will behave + add_filter( 'woocommerce_subscription_settings', __CLASS__ . '::add_settings' ); + + // When enabled, add the sync selection fields to the Edit Product screen + add_action( 'woocommerce_subscriptions_product_options_pricing', __CLASS__ . '::subscription_product_fields' ); + add_action( 'woocommerce_variable_subscription_pricing', __CLASS__ . '::variable_subscription_product_fields', 10, 3 ); + + // Add the translated fields to the Subscriptions admin script + add_filter( 'woocommerce_subscriptions_admin_script_parameters', __CLASS__ . '::admin_script_parameters', 10 ); + + // Save sync options when a subscription product is saved + add_action( 'woocommerce_process_product_meta_subscription', __CLASS__ . '::save_subscription_meta', 10 ); + + // Save sync options when a variable subscription product is saved + add_action( 'woocommerce_process_product_meta_variable-subscription', __CLASS__ . '::process_product_meta_variable_subscription' ); // WC < 2.4 + add_action( 'woocommerce_ajax_save_product_variations', __CLASS__ . '::process_product_meta_variable_subscription' ); + + // Make sure the expiration dates are calculated from the synced start date + add_filter( 'woocommerce_subscriptions_product_trial_expiration_date', __CLASS__ . '::recalculate_product_trial_expiration_date', 10, 2 ); + add_filter( 'woocommerce_subscriptions_product_expiration_date', __CLASS__ . '::recalculate_product_expiration_date', 10, 3 ); + + // Display a product's first payment date on the product's page to make sure it's obvious to the customer when payments will start + add_action( 'woocommerce_single_product_summary', __CLASS__ . '::products_first_payment_date', 31 ); + + // Display a product's first payment date on the product's page to make sure it's obvious to the customer when payments will start + add_action( 'woocommerce_subscriptions_product_first_renewal_payment_time', __CLASS__ . '::products_first_renewal_payment_time', 10, 4 ); + + // Maybe mock a free trial on the product for calculating totals and displaying correct shipping costs + add_filter( 'woocommerce_before_calculate_totals', __CLASS__ . '::maybe_set_free_trial', 0, 1 ); + add_action( 'woocommerce_subscription_cart_before_grouping', __CLASS__ . '::maybe_unset_free_trial' ); + add_action( 'woocommerce_subscription_cart_after_grouping', __CLASS__ . '::maybe_set_free_trial' ); + add_action( 'wcs_recurring_cart_start_date', __CLASS__ . '::maybe_unset_free_trial', 0, 1 ); + add_action( 'wcs_recurring_cart_end_date', __CLASS__ . '::maybe_set_free_trial', 100, 1 ); + add_filter( 'woocommerce_subscriptions_calculated_total', __CLASS__ . '::maybe_unset_free_trial', 10000, 1 ); + add_action( 'woocommerce_cart_totals_before_shipping', __CLASS__ . '::maybe_set_free_trial' ); + add_action( 'woocommerce_cart_totals_after_shipping', __CLASS__ . '::maybe_unset_free_trial' ); + add_action( 'woocommerce_review_order_before_shipping', __CLASS__ . '::maybe_set_free_trial' ); + add_action( 'woocommerce_review_order_after_shipping', __CLASS__ . '::maybe_unset_free_trial' ); + + // Set prorated initial amount when calculating initial total + add_filter( 'woocommerce_subscriptions_cart_get_price', __CLASS__ . '::set_prorated_price_for_calculation', 10, 2 ); + + // When creating a subscription check if it contains a synced product and make sure the correct meta is set on the subscription + add_action( 'save_post', __CLASS__ . '::maybe_add_subscription_meta', 10, 1 ); + + // When adding an item to a subscription, check if it is for a synced product to make sure the sync meta is set on the subscription. We can't attach to just the 'woocommerce_new_order_item' here because the '_product_id' and '_variation_id' meta are not set before it fires + add_action( 'woocommerce_ajax_add_order_item_meta', __CLASS__ . '::ajax_maybe_add_meta_for_item', 10, 2 ); + add_action( 'woocommerce_order_add_product', __CLASS__ . '::maybe_add_meta_for_new_product', 10, 3 ); + + // Make sure the sign-up fee for a synchronised subscription is correct + add_filter( 'woocommerce_subscriptions_sign_up_fee', __CLASS__ . '::get_synced_sign_up_fee', 1, 3 ); + + // Autocomplete subscription orders when they only contain a synchronised subscription + add_filter( 'woocommerce_payment_complete_order_status', __CLASS__ . '::order_autocomplete', 10, 2 ); + + // If it's an initial sync order and the total is zero, and nothing needs to be shipped, do not reduce stock + add_filter( 'woocommerce_order_item_quantity', __CLASS__ . '::maybe_do_not_reduce_stock', 10, 3 ); + + add_filter( 'woocommerce_subscriptions_recurring_cart_key', __CLASS__ . '::add_to_recurring_cart_key', 10, 2 ); + } + + /** + * Check if payment syncing is enabled on the store. + * + * @since 1.5 + */ + public static function is_syncing_enabled() { + return ( 'yes' == get_option( self::$setting_id, 'no' ) ) ? true : false; + } + + /** + * Check if payment syncing is enabled on the store. + * + * @since 1.5 + */ + public static function is_sync_proration_enabled() { + return ( 'no' != get_option( self::$setting_id_proration, 'no' ) ) ? true : false; + } + + /** + * Add sync settings to the Subscription's settings page. + * + * @since 1.5 + */ + public static function add_settings( $settings ) { + + // Get the index of the index of the + foreach ( $settings as $i => $setting ) { + if ( 'title' == $setting['type'] && 'woocommerce_subscriptions_miscellaneous' == $setting['id'] ) { + $index = $i; + break; + } + } + + array_splice( $settings, $index, 0, array( + + array( + 'name' => __( 'Synchronisation', 'woocommerce-subscriptions' ), + 'type' => 'title', + // translators: placeholders are opening and closing link tags + 'desc' => sprintf( _x( 'Align subscription renewal to a specific day of the week, month or year. For example, the first day of the month. %sLearn more%s.', 'used in the general subscription options page', 'woocommerce-subscriptions' ), '', '' ), + 'id' => self::$setting_id . '_title', + ), + + array( + 'name' => self::$sync_field_label, + 'desc' => __( 'Align Subscription Renewal Day', 'woocommerce-subscriptions' ), + 'id' => self::$setting_id, + 'default' => 'no', + 'type' => 'checkbox', + ), + + array( + 'name' => __( 'Prorate First Payment', 'woocommerce-subscriptions' ), + 'desc' => __( 'If a subscription is synchronised to a specific day of the week, month or year, charge a prorated amount for the subscription at the time of sign up.', 'woocommerce-subscriptions' ), + 'id' => self::$setting_id_proration, + 'css' => 'min-width:150px;', + 'default' => 'no', + 'type' => 'select', + 'options' => array( + 'no' => _x( 'Never', 'when to allow a setting', 'woocommerce-subscriptions' ), + 'virtual' => _x( 'For Virtual Subscription Products Only', 'when to prorate first payment / subscription length', 'woocommerce-subscriptions' ), + 'yes' => _x( 'For All Subscription Products', 'when to prorate first payment / subscription length', 'woocommerce-subscriptions' ), + ), + 'desc_tip' => true, + ), + + array( 'type' => 'sectionend', 'id' => self::$setting_id . '_title' ), + ) ); + + return $settings; + } + + /** + * Add the sync setting fields to the Edit Product screen + * + * @since 1.5 + */ + public static function subscription_product_fields() { + global $post, $wp_locale; + + if ( self::is_syncing_enabled() ) { + + // Set month as the default billing period + if ( ! $subscription_period = get_post_meta( $post->ID, '_subscription_period', true ) ) { + $subscription_period = 'month'; + } + + // Determine whether to display the week/month sync fields or the annual sync fields + $display_week_month_select = ( ! in_array( $subscription_period, array( 'month', 'week' ) ) ) ? 'display: none;' : ''; + $display_annual_select = ( 'year' != $subscription_period ) ? 'display: none;' : ''; + + $payment_day = self::get_products_payment_day( $post->ID ); + + // An annual sync date is already set in the form: array( 'day' => 'nn', 'month' => 'nn' ), create a MySQL string from those values (year and time are irrelvent as they are ignored) + if ( is_array( $payment_day ) ) { + $payment_month = $payment_day['month']; + $payment_day = $payment_day['day']; + } else { + $payment_month = date( 'm' ); + } + + echo '
    '; + echo '
    '; + + woocommerce_wp_select( array( + 'id' => self::$post_meta_key, + 'class' => 'wc_input_subscription_payment_sync', + 'label' => self::$sync_field_label . ':', + 'options' => self::get_billing_period_ranges( $subscription_period ), + 'description' => self::$sync_description, + 'desc_tip' => true, + 'value' => $payment_day, // Explicity set value in to ensure backward compatibility + ) + ); + + echo '
    '; + + echo '
    '; + + woocommerce_wp_text_input( array( + 'id' => self::$post_meta_key_day, + 'class' => 'wc_input_subscription_payment_sync', + 'label' => self::$sync_field_label . ':', + 'placeholder' => _x( 'Day', 'input field placeholder for day field for annual subscriptions', 'woocommerce-subscriptions' ), + 'value' => $payment_day, + 'type' => 'number', + ) + ); + + woocommerce_wp_select( array( + 'id' => self::$post_meta_key_month, + 'class' => 'wc_input_subscription_payment_sync', + 'label' => '', + 'options' => $wp_locale->month, + 'description' => self::$sync_description_year, + 'desc_tip' => true, + 'value' => $payment_month, // Explicity set value in to ensure backward compatibility + ) + ); + + echo '
    '; + echo '
    '; + + } + } + + /** + * Add the sync setting fields to the variation section of the Edit Product screen + * + * @since 1.5 + */ + public static function variable_subscription_product_fields( $loop, $variation_data, $variation ) { + + if ( self::is_syncing_enabled() ) { + + // Set month as the default billing period + if ( ! $subscription_period = get_post_meta( $variation->ID, '_subscription_period', true ) ) { + $subscription_period = 'month'; + } + + $display_week_month_select = ( ! in_array( $subscription_period, array( 'month', 'week' ) ) ) ? 'display: none;' : ''; + $display_annual_select = ( 'year' != $subscription_period ) ? 'display: none;' : ''; + + $payment_day = self::get_products_payment_day( $variation->ID ); + + // An annual sync date is already set in the form: array( 'day' => 'nn', 'month' => 'nn' ), create a MySQL string from those values (year and time are irrelvent as they are ignored) + if ( is_array( $payment_day ) ) { + $payment_month = $payment_day['month']; + $payment_day = $payment_day['day']; + } else { + $payment_month = date( 'm' ); + } + + include( plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/admin/html-variation-synchronisation.php' ); + } + } + + /** + * Save sync options when a subscription product is saved + * + * @since 1.5 + */ + public static function save_subscription_meta( $post_id ) { + + if ( empty( $_POST['_wcsnonce'] ) || ! wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_subscription_meta' ) ) { + return; + } + + // Set month as the default billing period + if ( ! isset( $_POST['_subscription_period'] ) ) { + $_POST['_subscription_period'] = 'month'; + } + + if ( 'year' == $_POST['_subscription_period'] ) { // save the day & month for the date rather than just the day + + $_POST[ self::$post_meta_key ] = array( + 'day' => isset( $_POST[ self::$post_meta_key_day ] ) ? $_POST[ self::$post_meta_key_day ] : 0, + 'month' => isset( $_POST[ self::$post_meta_key_month ] ) ? $_POST[ self::$post_meta_key_month ] : '01', + ); + + } else { + + if ( ! isset( $_POST[ self::$post_meta_key ] ) ) { + $_POST[ self::$post_meta_key ] = 0; + } + } + + update_post_meta( $post_id, self::$post_meta_key, $_POST[ self::$post_meta_key ] ); + } + + /** + * Save sync options when a variable subscription product is saved + * + * @since 1.5 + */ + public static function process_product_meta_variable_subscription( $post_id ) { + + if ( empty( $_POST['_wcsnonce_save_variations'] ) || ! wp_verify_nonce( $_POST['_wcsnonce_save_variations'], 'wcs_subscription_variations' ) || ! isset( $_POST['variable_post_id'] ) || ! is_array( $_POST['variable_post_id'] ) ) { + return; + } + + $variable_post_ids = $_POST['variable_post_id']; + + $max_loop = max( array_keys( $variable_post_ids ) ); + + // Make sure the parent product doesn't have a sync value (in case it was once a simple subscription) + update_post_meta( $post_id, self::$post_meta_key, 0 ); + + $day_field = 'variable' . self::$post_meta_key_day; + $month_field = 'variable' . self::$post_meta_key_month; + + // Save each variations details + for ( $i = 0; $i <= $max_loop; $i ++ ) { + + if ( ! isset( $variable_post_ids[ $i ] ) ) { + continue; + } + + $variation_id = absint( $variable_post_ids[ $i ] ); + + if ( 'year' == $_POST['variable_subscription_period'][ $i ] ) { // save the day & month for the date rather than just the day + + $_POST[ 'variable' . self::$post_meta_key ][ $i ] = array( + 'day' => isset( $_POST[ $day_field ][ $i ] ) ? $_POST[ $day_field ][ $i ] : 0, + 'month' => isset( $_POST[ $month_field ][ $i ] ) ? $_POST[ $month_field ][ $i ] : 0, + ); + + } else { + + if ( ! isset( $_POST[ 'variable' . self::$post_meta_key ][ $i ] ) ) { + $_POST[ 'variable' . self::$post_meta_key ][ $i ] = 0; + } + } + + if ( isset( $_POST[ 'variable' . self::$post_meta_key ][ $i ] ) ) { + update_post_meta( $variation_id, self::$post_meta_key, $_POST[ 'variable' . self::$post_meta_key ][ $i ] ); + } + } + } + + /** + * Add translated syncing options for our client side script + * + * @since 1.5 + */ + public static function admin_script_parameters( $script_parameters ) { + + // Get admin screen id + $screen = get_current_screen(); + + if ( 'product' == $screen->id ) { + + $billing_period_strings = self::get_billing_period_ranges(); + + $script_parameters['syncOptions'] = array( + 'week' => $billing_period_strings['week'], + 'month' => $billing_period_strings['month'], + ); + + } + + return $script_parameters; + } + + /** + * Determine whether a product, specified with $product, needs to have its first payment processed on a + * specific day (instead of at the time of sign-up). + * + * @return (bool) True is the product's first payment will be synced to a certain day. + * @since 1.5 + */ + public static function is_product_synced( $product ) { + + if ( ! is_object( $product ) ) { + $product = wc_get_product( $product ); + } + + if ( ! is_object( $product ) || ! self::is_syncing_enabled() || 'day' == $product->subscription_period || ! $product->is_type( array( 'subscription', 'variable-subscription', 'subscription_variation' ) ) ) { + return false; + } + + $payment_date = self::get_products_payment_day( $product ); + + return ( ( ! is_array( $payment_date ) && $payment_date > 0 ) || ( isset( $payment_date['day'] ) && $payment_date['day'] > 0 ) ) ? true : false; + } + + /** + * Determine whether a product, specified with $product, should have its first payment processed on a + * at the time of sign-up but prorated to the sync day. + * + * @since 1.5.10 + */ + public static function is_product_prorated( $product ) { + + if ( false === self::is_sync_proration_enabled() || false === self::is_product_synced( $product ) ) { + $is_product_prorated = false; + } elseif ( 'yes' == get_option( self::$setting_id_proration, 'no' ) && 0 == WC_Subscriptions_Product::get_trial_length( $product ) ) { + $is_product_prorated = true; + } elseif ( 'virtual' == get_option( self::$setting_id_proration, 'no' ) && $product->is_virtual() && 0 == WC_Subscriptions_Product::get_trial_length( $product ) ) { + $is_product_prorated = true; + } else { + $is_product_prorated = false; + } + + return $is_product_prorated; + } + + /** + * Get the day of the week, month or year on which a subscription's payments should be + * synchronised to. + * + * @return int The day the products payments should be processed, or 0 if the payments should not be sync'd to a specific day. + * @since 1.5 + */ + public static function get_products_payment_day( $product ) { + + if ( ! self::is_syncing_enabled() ) { + $payment_date = 0; + } elseif ( ! is_object( $product ) ) { + $payment_date = get_post_meta( $product, self::$post_meta_key, true ); + } elseif ( isset( $product->subscription_payment_sync_date ) ) { + $payment_date = $product->subscription_payment_sync_date; + } else { + $payment_date = 0; + } + + return $payment_date; + } + + /** + * Calculate the first payment date for a synced subscription. + * + * The date is calculated in UTC timezone. + * + * @param WC_Product $product A subscription product. + * @param string $type (optional) The format to return the first payment date in, either 'mysql' or 'timestamp'. Default 'mysql'. + * @param string $from_date (optional) The date to calculate the first payment from in GMT/UTC timzeone. If not set, it will use the current date. This should not include any trial period on the product. + * @since 1.5 + */ + public static function calculate_first_payment_date( $product, $type = 'mysql', $from_date = '' ) { + + if ( ! is_object( $product ) ) { + $product = WC_Subscriptions::get_product( $product ); + } + + if ( ! self::is_product_synced( $product ) ) { + return 0; + } + + $period = WC_Subscriptions_Product::get_period( $product ); + $trial_period = WC_Subscriptions_Product::get_trial_period( $product ); + $trial_length = WC_Subscriptions_Product::get_trial_length( $product ); + + $from_date_param = $from_date; + + if ( empty( $from_date ) ) { + $from_date = gmdate( 'Y-m-d H:i:s' ); + } + + // If the subscription has a free trial period, the first payment should be synced to a day after the free trial + if ( $trial_length > 0 ) { + $from_date = WC_Subscriptions_Product::get_trial_expiration_date( $product, $from_date ); + } + + $from_timestamp = strtotime( $from_date ) + ( get_option( 'gmt_offset' ) * 3600 ); // Site time + + $payment_day = self::get_products_payment_day( $product ); + + if ( 'week' == $period ) { + + // strtotime() will figure out if the day is in the future or today (see: https://gist.github.com/thenbrent/9698083) + $first_payment_timestamp = strtotime( self::$weekdays[ $payment_day ], $from_timestamp ); + + } elseif ( 'month' == $period ) { + + // strtotime() needs to know the month, so we need to determine if the specified day has occured this month yet or if we want the last day of the month (see: https://gist.github.com/thenbrent/9698083) + if ( $payment_day > 27 ) { // we actually want the last day of the month + + $payment_day = gmdate( 't', $from_timestamp ); + $month = gmdate( 'F', $from_timestamp ); + + } elseif ( gmdate( 'j', $from_timestamp ) > $payment_day ) { // today is later than specified day in the from date, we need the next month + + $month = date( 'F', wcs_add_months( $from_timestamp, 1 ) ); + + } else { // specified day is either today or still to come in the month of the from date + + $month = gmdate( 'F', $from_timestamp ); + + } + + $first_payment_timestamp = strtotime( "{$payment_day} {$month}", $from_timestamp ); + + } elseif ( 'year' == $period ) { + + // We can't use $wp_locale here because it is translated + switch ( $payment_day['month'] ) { + case 1 : + $month = 'January'; + break; + case 2 : + $month = 'February'; + break; + case 3 : + $month = 'March'; + break; + case 4 : + $month = 'April'; + break; + case 5 : + $month = 'May'; + break; + case 6 : + $month = 'June'; + break; + case 7 : + $month = 'July'; + break; + case 8 : + $month = 'August'; + break; + case 9 : + $month = 'September'; + break; + case 10 : + $month = 'October'; + break; + case 11 : + $month = 'November'; + break; + case 12 : + $month = 'December'; + break; + } + + $first_payment_timestamp = strtotime( "{$payment_day['day']} {$month}", $from_timestamp ); + } + + // Make sure the next payment is in the future and after the $from_date, as strtotime() will return the date this year for any day in the past when adding months or years (see: https://gist.github.com/thenbrent/9698083) + if ( 'year' == $period || 'month' == $period ) { + + // First make sure the day is in the past so that we don't end up jumping a month or year because of a few hours difference between now and the billing date + if ( gmdate( 'Ymd', $first_payment_timestamp ) < gmdate( 'Ymd', $from_timestamp ) || gmdate( 'Ymd', $first_payment_timestamp ) < gmdate( 'Ymd' ) ) { + $i = 1; + // Then make sure the date and time of the payment is in the future + while ( ( $first_payment_timestamp < gmdate( 'U' ) || $first_payment_timestamp < $from_timestamp ) && $i < 30 ) { + $first_payment_timestamp = strtotime( "+ 1 {$period}", $first_payment_timestamp ); + $i = $i + 1; + } + } + } + + // We calculated a timestamp for midnight on the specific day in the site's timezone, let's push it to 3am to account for any daylight savings changes + $first_payment_timestamp += 3 * HOUR_IN_SECONDS; + + // And convert it to the UTC equivalent of 3am on that day + $first_payment_timestamp -= ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); + + $first_payment = ( 'mysql' == $type && 0 != $first_payment_timestamp ) ? date( 'Y-m-d H:i:s', $first_payment_timestamp ) : $first_payment_timestamp; + + return apply_filters( 'woocommerce_subscriptions_synced_first_payment_date', $first_payment, $product, $type, $from_date, $from_date_param ); + } + + /** + * Return an i18n'ified associative array of all possible subscription periods. + * + * @since 1.5 + */ + public static function get_billing_period_ranges( $billing_period = '' ) { + global $wp_locale; + + if ( empty( self::$billing_period_ranges ) ) { + + foreach ( array( 'week', 'month', 'year' ) as $key ) { + self::$billing_period_ranges[ $key ][0] = __( 'Do not synchronise', 'woocommerce-subscriptions' ); + } + + // Week + $weekdays = array_merge( $wp_locale->weekday, array( $wp_locale->weekday[0] ) ); + unset( $weekdays[0] ); + foreach ( $weekdays as $i => $weekly_billing_period ) { + // translators: placeholder is a day of the week + self::$billing_period_ranges['week'][ $i ] = sprintf( __( '%s each week', 'woocommerce-subscriptions' ), $weekly_billing_period ); + } + + // Month + foreach ( range( 1, 27 ) as $i ) { + // translators: placeholder is a number of day with language specific suffix applied (e.g. "1st", "3rd", "5th", etc...) + self::$billing_period_ranges['month'][ $i ] = sprintf( __( '%s day of the month', 'woocommerce-subscriptions' ), WC_Subscriptions::append_numeral_suffix( $i ) ); + } + self::$billing_period_ranges['month'][28] = __( 'Last day of the month', 'woocommerce-subscriptions' ); + + self::$billing_period_ranges = apply_filters( 'woocommerce_subscription_billing_period_ranges', self::$billing_period_ranges ); + } + + if ( empty( $billing_period ) ) { + return self::$billing_period_ranges; + } elseif ( isset( self::$billing_period_ranges[ $billing_period ] ) ) { + return self::$billing_period_ranges[ $billing_period ]; + } else { + return array(); + } + } + + /** + * Add the first payment date to a products summary section + * + * @since 1.5 + */ + public static function products_first_payment_date( $echo = false ) { + global $product; + + $first_payment_date = '

    ' . self::get_products_first_payment_date( $product ) . '

    '; + + if ( false !== $echo ) { + echo wp_kses( $first_payment_date, array( 'p' => array( 'class' => array() ), 'small' => array() ) ); + } + + return $first_payment_date; + } + + /** + * Return a string explaining when the first payment will be completed for the subscription. + * + * @since 1.5 + */ + public static function get_products_first_payment_date( $product ) { + + $first_payment_date = ''; + + if ( self::is_product_synced( $product ) ) { + $first_payment_timestamp = self::calculate_first_payment_date( $product->id, 'timestamp' ); + + if ( 0 != $first_payment_timestamp ) { + + $is_first_payment_today = self::is_today( $first_payment_timestamp ); + + if ( $is_first_payment_today ) { + $payment_date_string = __( 'Today!', 'woocommerce-subscriptions' ); + } else { + $payment_date_string = date_i18n( wc_date_format(), $first_payment_timestamp + ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ); + } + + if ( self::is_product_prorated( $product ) && ! $is_first_payment_today ) { + // translators: placeholder is a date + $first_payment_date = sprintf( __( 'First payment prorated. Next payment: %s', 'woocommerce-subscriptions' ), $payment_date_string ); + } else { + // translators: placeholder is a date + $first_payment_date = sprintf( __( 'First payment: %s', 'woocommerce-subscriptions' ), $payment_date_string ); + } + + $first_payment_date = '' . $first_payment_date . ''; + } + } + + return apply_filters( 'woocommerce_subscriptions_synced_first_payment_date_string', $first_payment_date, $product ); + } + + /** + * If a product is synchronised to a date in the future, make sure that is set as the product's first payment date + * + * @since 2.0 + */ + public static function products_first_renewal_payment_time( $first_renewal_timestamp, $product_id, $from_date, $timezone ) { + + if ( self::is_product_synced( $product_id ) ) { + + $next_renewal_timestamp = self::calculate_first_payment_date( $product_id, 'timestamp', $from_date ); + + if ( ! self::is_today( $next_renewal_timestamp ) ) { + + if ( 'site' == $timezone ) { + $next_renewal_timestamp += ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); + } + $first_renewal_timestamp = $next_renewal_timestamp; + } + } + + return $first_renewal_timestamp; + } + + /** + * Make sure a synchronised subscription's price includes a free trial, unless it's first payment is today. + * + * @since 1.5 + */ + public static function maybe_set_free_trial( $total = '' ) { + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + if ( self::is_product_synced( $cart_item['data'] ) && ! self::is_product_prorated( $cart_item['data'] ) && ! self::is_today( self::calculate_first_payment_date( $cart_item['data'], 'timestamp' ) ) ) { + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_trial_length = ( WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_trial_length > 1 ) ? WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_trial_length : 1; + } + } + + return $total; + } + + /** + * Make sure a synchronised subscription's price includes a free trial, unless it's first payment is today. + * + * @since 1.5 + */ + public static function maybe_unset_free_trial( $total = '' ) { + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + if ( self::is_product_synced( $cart_item['data'] ) ) { + WC()->cart->cart_contents[ $cart_item_key ]['data']->subscription_trial_length = WC_Subscriptions_Product::get_trial_length( wcs_get_canonical_product_id( $cart_item ) ); + } + } + return $total; + } + + /** + * Check if the cart includes a subscription that needs to be synced. + * + * @return bool Returns true if any item in the cart is a subscription sync request, otherwise, false. + * @since 1.5 + */ + public static function cart_contains_synced_subscription( $cart = null ) { + $cart = ( empty( $cart ) && isset( WC()->cart ) ) ? WC()->cart : $cart; + $contains_synced = false; + + if ( self::is_syncing_enabled() && ! empty( $cart ) && ! wcs_cart_contains_renewal() ) { + + foreach ( $cart->cart_contents as $cart_item_key => $cart_item ) { + if ( ( ! is_array( $cart_item['data']->subscription_payment_sync_date ) && $cart_item['data']->subscription_payment_sync_date > 0 ) || ( is_array( $cart_item['data']->subscription_payment_sync_date ) && $cart_item['data']->subscription_payment_sync_date['day'] > 0 ) ) { + $contains_synced = $cart_item; + break; + } + } + } + + return $contains_synced; + } + + /** + * Maybe set the time of a product's trial expiration to be the same as the synced first payment date for products where the first + * renewal payment date falls on the same day as the trial expiration date, but the trial expiration time is later in the day. + * + * When making sure the first payment is after the trial expiration in @see self::calculate_first_payment_date() we only check + * whether the first payment day comes after the trial expiration day, because we don't want to pushing the first payment date + * a month or year in the future because of a few hours difference between it and the trial expiration. However, this means we + * could still end up with a trial end time after the first payment time, even though they are both on the same day because the + * trial end time is normally calculated from the start time, which can be any time of day, but the first renewal time is always + * set to be 3am in the site's timezone. For example, the first payment date might be calculate to be 3:00 on the 21st April 2017, + * while the trial end date is on the same day at 3:01 (or any time after that on the same day). So we need to check both the time and day. We also don't want to make the first payment date/time skip a year because of a few hours difference. That means we need to either modify the trial end time to be 3:00am or make the first payment time occur at the same time as the trial end time. The former is pretty hard to change, but the later will sync'd payments will be at a different times if there is a free trial ending on the same day, which could be confusing. o_0 + * + * Fixes #1328 + * + * @param mixed $trial_expiration_date MySQL formatted date on which the subscription's trial will end, or 0 if it has no trial + * @param mixed $product_id The product object or post ID of the subscription product + * @return mixed MySQL formatted date on which the subscription's trial is set to end, or 0 if it has no trial + * @since 2.0.13 + */ + public static function recalculate_product_trial_expiration_date( $trial_expiration_date, $product_id ) { + + if ( $trial_expiration_date > 0 && self::is_product_synced( $product_id ) ) { + + $trial_expiration_timestamp = strtotime( $trial_expiration_date ); + remove_filter( 'woocommerce_subscriptions_product_trial_expiration_date', __METHOD__ ); // avoid infinite loop + $first_payment_timestamp = self::calculate_first_payment_date( $product_id, 'timestamp' ); + add_filter( 'woocommerce_subscriptions_product_trial_expiration_date', __METHOD__, 10, 2 ); // avoid infinite loop + + // First make sure the day is in the past so that we don't end up jumping a month or year because of a few hours difference between now and the billing date + if ( $trial_expiration_timestamp > $first_payment_timestamp && gmdate( 'Ymd', $first_payment_timestamp ) == gmdate( 'Ymd', $trial_expiration_timestamp ) ) { + $trial_expiration_date = date( 'Y-m-d H:i:s', $first_payment_timestamp ); + } + } + + return $trial_expiration_date; + } + + /** + * Make sure the expiration date is calculated from the synced start date for products where the start date + * will be synced. + * + * @param string $expiration_date MySQL formatted date on which the subscription is set to expire + * @param mixed $product_id The product/post ID of the subscription + * @param mixed $from_date A MySQL formatted date/time string from which to calculate the expiration date, or empty (default), which will use today's date/time. + * @since 1.5 + */ + public static function recalculate_product_expiration_date( $expiration_date, $product_id, $from_date ) { + + if ( self::is_product_synced( $product_id ) && ( $subscription_length = WC_Subscriptions_Product::get_length( $product_id ) ) > 0 ) { + + $subscription_period = WC_Subscriptions_Product::get_period( $product_id ); + $first_payment_date = self::calculate_first_payment_date( $product_id, 'timestamp' ); + + $expiration_date = date( 'Y-m-d H:i:s', wcs_add_time( $subscription_length, $subscription_period, $first_payment_date ) ); + } + + return $expiration_date; + } + + /** + * Check if a given timestamp (in the UTC timezone) is equivalent to today in the site's time. + * + * @param int $timestamp A time in UTC timezone to compare to today. + */ + public static function is_today( $timestamp ) { + + // Convert timestamp to site's time + $timestamp += get_option( 'gmt_offset' ) * HOUR_IN_SECONDS; + + return ( gmdate( 'Y-m-d', current_time( 'timestamp' ) ) == date( 'Y-m-d', $timestamp ) ) ? true : false; + } + + /** + * Filters WC_Subscriptions_Order::get_sign_up_fee() to make sure the sign-up fee for a subscription product + * that is synchronised is returned correctly. + * + * @param float The initial sign-up fee charged when the subscription product in the order was first purchased, if any. + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return float The initial sign-up fee charged when the subscription product in the order was first purchased, if any. + * @since 2.0 + */ + public static function get_synced_sign_up_fee( $sign_up_fee, $subscription, $product_id ) { + + if ( wcs_is_subscription( $subscription ) && self::subscription_contains_synced_product( $subscription ) && count( wcs_get_line_items_with_a_trial( $subscription->id ) ) < 0 ) { + $sign_up_fee = max( $subscription->get_total_initial_payment() - $subscription->get_total(), 0 ); + } + + return $sign_up_fee; + } + + /** + * Removes the "set_subscription_prices_for_calculation" filter from the WC Product's woocommerce_get_price hook once + * + * @since 1.5.10 + */ + public static function set_prorated_price_for_calculation( $price, $product ) { + + if ( WC_Subscriptions_Product::is_subscription( $product ) && self::is_product_prorated( $product ) && 'none' == WC_Subscriptions_Cart::get_calculation_type() ) { + + $next_payment_date = self::calculate_first_payment_date( $product, 'timestamp' ); + + if ( self::is_today( $next_payment_date ) ) { + return $price; + } + + switch ( $product->subscription_period ) { + case 'week' : + $days_in_cycle = 7 * $product->subscription_period_interval; + break; + case 'month' : + $days_in_cycle = date( 't' ) * $product->subscription_period_interval; + break; + case 'year' : + $days_in_cycle = ( 365 + date( 'L' ) ) * $product->subscription_period_interval; + break; + } + + $days_until_next_payment = ceil( ( $next_payment_date - gmdate( 'U' ) ) / ( 60 * 60 * 24 ) ); + + $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); + + if ( $sign_up_fee > 0 && 0 == WC_Subscriptions_Product::get_trial_length( $product ) ) { + $price = $sign_up_fee + ( $days_until_next_payment * ( ( $price - $sign_up_fee ) / $days_in_cycle ) ); + } else { + $price = $days_until_next_payment * ( $price / $days_in_cycle ); + } + + // Now round the amount to the number of decimals displayed for prices to avoid rounding errors in the total calculations (we don't want to use WC_DISCOUNT_ROUNDING_PRECISION here because it can still lead to rounding errors). For full details, see: https://github.com/Prospress/woocommerce-subscriptions/pull/1134#issuecomment-178395062 + $price = round( $price, wc_get_price_decimals() ); + } + + return $price; + } + + /** + * Retrieve the full translated weekday word. + * + * Week starts on translated Monday and can be fetched + * by using 1 (one). So the week starts with 1 (one) + * and ends on Sunday with is fetched by using 7 (seven). + * + * @since 1.5.8 + * @access public + * + * @param int $weekday_number 1 for Monday through 7 Sunday + * @return string Full translated weekday + */ + public static function get_weekday( $weekday_number ) { + global $wp_locale; + + if ( 7 == $weekday_number ) { + $weekday = $wp_locale->get_weekday( 0 ); + } else { + $weekday = $wp_locale->get_weekday( $weekday_number ); + } + + return $weekday; + } + + /** + * Automatically set the order's status to complete if all the subscriptions in an order + * are synced and the order total is zero. + * + * @since 1.5.17 + */ + public static function order_autocomplete( $new_order_status, $order_id ) { + + $order = wc_get_order( $order_id ); + + if ( 'processing' == $new_order_status && $order->get_total() == 0 && wcs_order_contains_subscription( $order ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order_id ); + $all_synced = true; + + foreach ( $subscriptions as $subscription_id => $subscription ) { + + if ( ! self::subscription_contains_synced_product( $subscription_id ) ) { + $all_synced = false; + break; + } + } + + if ( $all_synced ) { + $new_order_status = 'completed'; + } + } + + return $new_order_status; + } + + /** + * Override quantities used to lower stock levels by when using synced subscriptions. If it's a synced product + * that does not have proration enabled and the payment date is not today, do not lower stock levels. + * + * @param integer $qty the original quantity that would be taken out of the stock level + * @param array $order order data + * @param array $item item data for each item in the order + * + * @return int + */ + public static function maybe_do_not_reduce_stock( $qty, $order, $order_item ) { + if ( wcs_order_contains_subscription( $order, array( 'parent', 'resubscribe' ) ) && 0 == $order_item['line_total'] ) { + $subscriptions = wcs_get_subscriptions_for_order( $order ); + $product_id = wcs_get_canonical_product_id( $order_item ); + + foreach ( $subscriptions as $subscription ) { + if ( self::subscription_contains_synced_product( $subscription ) && $subscription->has_product( $product_id ) ) { + foreach ( $subscription->get_items() as $subscription_item ) { + if ( wcs_get_canonical_product_id( $subscription_item ) == $product_id && 0 < $subscription_item['line_total'] ) { + $qty = 0; + } + } + } + } + } + return $qty; + } + + /** + * Add subscription meta for subscription that contains a synced product. + * + * @param WC_Order Parent order for the subscription + * @param WC_Subscription new subscription + * @since 2.0 + */ + public static function maybe_add_subscription_meta( $post_id ) { + + if ( 'shop_subscription' == get_post_type( $post_id ) && ! self::subscription_contains_synced_product( $post_id ) ) { + + $subscription = wcs_get_subscription( $post_id ); + + foreach ( $subscription->get_items() as $item ) { + $product_id = wcs_get_canonical_product_id( $item ); + + if ( self::is_product_synced( $product_id ) ) { + update_post_meta( $subscription->id, '_contains_synced_subscription', 'true' ); + break; + } + } + } + } + + /** + * When adding an item to an order/subscription via the Add/Edit Subscription administration interface, check if we should be setting + * the sync meta on the subscription. + * + * @param int The order item ID of an item that was just added to the order + * @param array The order item details + * @since 2.0 + */ + public static function ajax_maybe_add_meta_for_item( $item_id, $item ) { + + check_ajax_referer( 'order-item', 'security' ); + + if ( self::is_product_synced( wcs_get_canonical_product_id( $item ) ) ) { + self::maybe_add_subscription_meta( absint( $_POST['order_id'] ) ); + } + } + + /** + * When adding a product to an order/subscription via the WC_Subscription::add_product() method, check if we should be setting + * the sync meta on the subscription. + * + * @param int The post ID of a WC_Order or child object + * @param int The order item ID of an item that was just added to the order + * @param object The WC_Product for which an item was just added + * @since 2.0 + */ + public static function maybe_add_meta_for_new_product( $subscription_id, $item_id, $product ) { + if ( self::is_product_synced( $product ) ) { + self::maybe_add_subscription_meta( $subscription_id ); + } + } + + /** + * Check if a given subscription is synced to a certain day. + * + * @param int|WC_Subscription Accepts either a subscription object of post id + * @return bool + * @since 2.0 + */ + public static function subscription_contains_synced_product( $subscription_id ) { + + if ( is_object( $subscription_id ) ) { + $subscription_id = $subscription_id->id; + } + + return ( 'true' == get_post_meta( $subscription_id, '_contains_synced_subscription', true ) ) ? true : false; + } + + /** + * If the cart item is synced, add a '_synced' string to the recurring cart key. + * + * @since 2.0 + */ + public static function add_to_recurring_cart_key( $cart_key, $cart_item ) { + $product = $cart_item['data']; + + if ( self::is_product_synced( $product ) ) { + $cart_key .= '_synced'; + } + + return $cart_key; + } + + /* Deprecated Functions */ + + /** + * Add the first payment date to the end of the subscription to clarify when the first payment will be processed + * + * Deprecated because the first renewal date is displayed by default now on recurring totals. + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function customise_subscription_price_string( $subscription_string ) { + _deprecated_function( __METHOD__, '2.0' ); + + $cart_item = self::cart_contains_synced_subscription(); + + if ( false !== $cart_item && isset( $cart_item['data']->subscription_period ) && ( 'year' != $cart_item['data']->subscription_period || $cart_item['data']->subscription_trial_length > 0 ) ) { + + $first_payment_date = self::get_products_first_payment_date( $cart_item['data'] ); + + if ( '' != $first_payment_date ) { + + $price_and_start_date = sprintf( '%s
    %s', $subscription_string, $first_payment_date ); + + $subscription_string = apply_filters( 'woocommerce_subscriptions_synced_start_date_string', $price_and_start_date, $subscription_string, $cart_item ); + } + } + + return $subscription_string; + } + + /** + * Hid the trial period for a synchronised subscription unless the related product actually has a trial period (because + * we use a trial period to set the original order totals to 0). + * + * Deprecated because free trials are no longer displayed on cart totals, only the first renewal date is displayed. + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function maybe_hide_free_trial( $subscription_details ) { + _deprecated_function( __METHOD__, '2.0' ); + + $cart_item = self::cart_contains_synced_subscription(); + + if ( false !== $cart_item && ! self::is_product_prorated( $cart_item['data'] ) ) { // cart contains a sync + + $product_id = WC_Subscriptions_Cart::get_items_product_id( $cart_item ); + + if ( wc_price( 0 ) == $subscription_details['initial_amount'] && 0 == $subscription_details['trial_length'] ) { + $subscription_details['initial_amount'] = ''; + } + } + + return $subscription_details; + } + + /** + * Let other functions know shipping should not be charged on the initial order when + * the cart contains a synchronised subscription and no other items which need shipping. + * + * @since 1.5.8 + * @deprecated 2.0 + */ + public static function charge_shipping_up_front( $charge_shipping_up_front ) { + _deprecated_function( __METHOD__, '2.0' ); + + // the cart contains only the synchronised subscription + if ( true === $charge_shipping_up_front && self::cart_contains_synced_subscription() ) { + + // the cart contains only a subscription, see if the payment date is today and if not, then it doesn't need shipping + if ( 1 == count( WC()->cart->cart_contents ) ) { + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + if ( self::is_product_synced( $cart_item['data'] ) && ! self::is_product_prorated( $cart_item['data'] ) && ! self::is_today( self::calculate_first_payment_date( $cart_item['data'], 'timestamp' ) ) ) { + $charge_shipping_up_front = false; + break; + } + } + + // cart contains other items, see if any require shipping + } else { + + $other_items_need_shipping = false; + + foreach ( WC()->cart->cart_contents as $cart_item_key => $cart_item ) { + if ( ( ! WC_Subscriptions_Product::is_subscription( $cart_item['data'] ) || self::is_product_prorated( $cart_item['data'] ) ) && $cart_item['data']->needs_shipping() ) { + $other_items_need_shipping = true; + } + } + + if ( false === $other_items_need_shipping ) { + $charge_shipping_up_front = false; + } + } + } + + return $charge_shipping_up_front; + } + + /** + * Make sure anything requesting the first payment date for a synced subscription on the front-end receives + * a date which takes into account the day on which payments should be processed. + * + * This is necessary as the self::calculate_first_payment_date() is not called when the subscription is active + * (which it isn't until the first payment is completed and the subscription is activated). + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function get_first_payment_date( $first_payment_date, $order, $product_id, $type ) { + + _deprecated_function( __METHOD__, '2.0' ); + + $subscription = wcs_get_subscription_from_key( $order . '_' . $product_id ); + + if ( self::order_contains_synced_subscription( $order->id ) && 1 >= $subscription->get_completed_payment_count() ) { + + // Don't prematurely set the first payment date when manually adding a subscription from the admin + if ( ! is_admin() || 'active' == $subscription->get_status() ) { + + $first_payment_timestamp = self::calculate_first_payment_date( $product_id, 'timestamp', $order->order_date ); + + if ( 0 != $first_payment_timestamp ) { + $first_payment_date = ( 'mysql' == $type ) ? date( 'Y-m-d H:i:s', $first_payment_timestamp ) : $first_payment_timestamp; + } + } + } + + return $first_payment_date; + } + + /** + * Tell anything hooking to 'woocommerce_subscriptions_calculated_next_payment_date' + * to use the synchronised first payment date as the next payment date (if the first + * payment date isn't today, meaning the first payment won't be charged today). + * + * @since 1.5.14 + * @deprecated 2.0 + */ + public static function maybe_set_payment_date( $payment_date, $order, $product_id, $type ) { + + _deprecated_function( __METHOD__, '2.0' ); + + $first_payment_date = self::get_first_payment_date( $payment_date, $order, $product_id, 'timestamp' ); + + if ( ! self::is_today( $first_payment_date ) ) { + $payment_date = ( 'timestamp' == $type ) ? $first_payment_date : date( 'Y-m-d H:i:s', $first_payment_date ); + } + + return $payment_date; + } + + /** + * Check if a given order included a subscription that is synced to a certain day. + * + * Deprecated becasuse _order_contains_synced_subscription is no longer stored on the order @see self::subscription_contains_synced_product + * + * @param int $order_id The ID or a WC_Order item to check. + * @return bool Returns true if the order contains a synced subscription, otherwise, false. + * @since 1.5 + * @deprecated 2.0 + */ + public static function order_contains_synced_subscription( $order_id ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::subscription_contains_synced_product()' ); + + if ( is_object( $order_id ) ) { + $order_id = $order_id->id; + } + + return ( 'true' == get_post_meta( $order_id, '_order_contains_synced_subscription', true ) ) ? true : false; + } + + /** + * If the order being generated is for a synced subscription, keep a record of the syncing related meta data. + * + * Deprecated because _order_contains_synced_subscription is no longer stored on the order @see self::add_subscription_sync_meta + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function add_order_meta( $order_id, $posted ) { + _deprecated_function( __METHOD__, '2.0' ); + global $woocommerce; + + if ( $cart_item = self::cart_contains_synced_subscription() ) { + update_post_meta( $order_id, '_order_contains_synced_subscription', 'true' ); + } + } + + /** + * If the subscription being generated is synced, set the syncing related meta data correctly. + * + * Deprecated because editing a subscription's values is now done from the Edit Subscription screen. + * + * @since 1.5 + * @deprecated 2.0 + */ + public static function prefill_order_item_meta( $item, $item_id ) { + + _deprecated_function( __METHOD__, '2.0' ); + + return $item; + } + + /** + * Filters WC_Subscriptions_Order::get_sign_up_fee() to make sure the sign-up fee for a subscription product + * that is synchronised is returned correctly. + * + * @param float The initial sign-up fee charged when the subscription product in the order was first purchased, if any. + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param int $product_id The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order. + * @return float The initial sign-up fee charged when the subscription product in the order was first purchased, if any. + * @since 1.5.3 + * @deprecated 2.0 + */ + public static function get_sign_up_fee( $sign_up_fee, $order, $product_id, $non_subscription_total ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::get_synced_sign_up_fee' ); + + if ( 'shop_order' == get_post_type( $order ) && self::order_contains_synced_subscription( $order->id ) && WC_Subscriptions_Order::get_subscription_trial_length( $order ) < 1 ) { + $sign_up_fee = max( WC_Subscriptions_Order::get_total_initial_payment( $order ) - $non_subscription_total, 0 ); + } + + return $sign_up_fee; + } + + /** + * Check if the cart includes a subscription that needs to be prorated. + * + * @return bool Returns any item in the cart that is synced and requires proration, otherwise, false. + * @since 1.5 + * @deprecated 2.0 + */ + public static function cart_contains_prorated_subscription() { + _deprecated_function( __METHOD__, '2.0' ); + $cart_contains_prorated_subscription = false; + + $synced_cart_item = self::cart_contains_synced_subscription(); + + if ( false !== $synced_cart_item && self::is_product_prorated( $synced_cart_item['data'] ) ) { + $cart_contains_prorated_subscription = $synced_cart_item; + } + + return $cart_contains_prorated_subscription; + } + + /** + * Maybe recalculate the trial end date for synced subscription products that contain the unnecessary + * "one day trial" period. + * + * @since 2.0 + * @deprecated 2.0.14 + */ + public static function recalculate_trial_end_date( $trial_end_date, $recurring_cart, $product ) { + _deprecated_function( __METHOD__, '2.0.14' ); + if ( self::is_product_synced( $product ) ) { + $product_id = ( isset( $product->variation_id ) ) ? $product->variation_id : $product->id; + $trial_end_date = WC_Subscriptions_Product::get_trial_expiration_date( $product_id ); + } + + return $trial_end_date; + } + + /** + * Maybe recalculate the end date for synced subscription products that contain the unnecessary + * "one day trial" period. + * + * @since 2.0.9 + * @deprecated 2.0.14 + */ + public static function recalculate_end_date( $end_date, $recurring_cart, $product ) { + _deprecated_function( __METHOD__, '2.0.14' ); + if ( self::is_product_synced( $product ) ) { + $product_id = ( isset( $product->variation_id ) ) ? $product->variation_id : $product->id; + $end_date = WC_Subscriptions_Product::get_expiration_date( $product_id ); + } + + return $end_date; + } + +} +add_action( 'init', 'WC_Subscriptions_Synchroniser::init' ); + diff --git a/includes/class-wcs-action-scheduler.php b/includes/class-wcs-action-scheduler.php new file mode 100644 index 0000000..4674bd5 --- /dev/null +++ b/includes/class-wcs-action-scheduler.php @@ -0,0 +1,157 @@ + $date_type values */ + protected $action_hooks = array( + 'woocommerce_scheduled_subscription_trial_end' => 'trial_end', + 'woocommerce_scheduled_subscription_payment' => 'next_payment', + 'woocommerce_scheduled_subscription_expiration' => 'end', + + ); + + /** + * Maybe set a schedule action if the new date is in the future + * + * @param object $subscription An instance of a WC_Subscription object + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'end', 'end_of_prepaid_term' or a custom date type + * @param string $datetime A MySQL formated date/time string in the GMT/UTC timezone. + */ + public function update_date( $subscription, $date_type, $datetime ) { + + if ( in_array( $date_type, $this->date_types_to_schedule ) ) { + + $action_hook = $this->get_scheduled_action_hook( $subscription, $date_type ); + + if ( ! empty( $action_hook ) ) { + + $action_args = array( 'subscription_id' => $subscription->id ); + $timestamp = strtotime( $datetime ); + $next_scheduled = wc_next_scheduled_action( $action_hook, $action_args ); + + if ( $next_scheduled !== $timestamp ) { + + // Maybe clear the existing schedule for this hook + if ( false !== $next_scheduled ) { + wc_unschedule_action( $action_hook, $action_args ); + } + + // Only reschedule if it's in the future + if ( $timestamp > current_time( 'timestamp', true ) && 'active' == $subscription->get_status() ) { + wc_schedule_single_action( $datetime, $action_hook, $action_args ); + } + } + } + } + } + + /** + * Delete a date from the action scheduler queue + * + * @param object $subscription An instance of a WC_Subscription object + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'end', 'end_of_prepaid_term' or a custom date type + */ + public function delete_date( $subscription, $date_type ) { + $this->update_date( $subscription, $date_type, 0 ); + } + + /** + * When a subscription's status is updated, maybe schedule an event + * + * @param object $subscription An instance of a WC_Subscription object + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'end', 'end_of_prepaid_term' or a custom date type + * @param string $datetime A MySQL formated date/time string in the GMT/UTC timezone. + */ + public function update_status( $subscription, $new_status, $old_status ) { + + $action_args = array( 'subscription_id' => $subscription->id ); + + switch ( $new_status ) { + case 'active' : + + foreach ( $this->action_hooks as $action_hook => $date_type ) { + + $next_scheduled = wc_next_scheduled_action( $action_hook, $action_args ); + $event_time = $subscription->get_time( $date_type ); + + // Maybe clear the existing schedule for this hook + if ( false !== $next_scheduled && $next_scheduled != $event_time ) { + wc_unschedule_action( $action_hook, $action_args ); + } + + if ( 0 != $event_time && $event_time > current_time( 'timestamp', true ) && $next_scheduled != $event_time ) { + wc_schedule_single_action( $event_time, $action_hook, $action_args ); + } + } + break; + case 'pending-cancel' : + + $end_time = $subscription->get_time( 'end' ); // This will have been set to the correct date already + + // Now that we have the current times, clear the scheduled hooks + foreach ( $this->action_hooks as $action_hook => $date_type ) { + wc_unschedule_action( $action_hook, $action_args ); + } + + $next_scheduled = wc_next_scheduled_action( 'woocommerce_scheduled_subscription_end_of_prepaid_term', $action_args ); + + if ( false !== $next_scheduled && $next_scheduled != $end_time ) { + wc_unschedule_action( 'woocommerce_scheduled_subscription_end_of_prepaid_term', $action_args ); + } + + // The end date was set in WC_Subscriptions::update_dates() to the appropriate value, so we can schedule our action for that time + if ( $end_time > current_time( 'timestamp', true ) && $next_scheduled != $end_time ) { + wc_schedule_single_action( $end_time, 'woocommerce_scheduled_subscription_end_of_prepaid_term', $action_args ); + } + break; + case 'on-hold' : + case 'cancelled' : + case 'switched' : + case 'expired' : + case 'trash' : + foreach ( $this->action_hooks as $action_hook => $date_type ) { + wc_unschedule_action( $action_hook, $action_args ); + } + wc_unschedule_action( 'woocommerce_scheduled_subscription_end_of_prepaid_term', $action_args ); + break; + } + } + + /** + * Get the hook to use in the action scheduler for the date type + * + * @param string $date_type Can be 'start', 'trial_end', 'next_payment', 'last_payment', 'expiration', 'end_of_prepaid_term' or a custom date type + * @param object $subscription An instance of WC_Subscription to get the hook for + */ + protected function get_scheduled_action_hook( $subscription, $date_type ) { + + $hook = ''; + + switch ( $date_type ) { + case 'next_payment' : + $hook = 'woocommerce_scheduled_subscription_payment'; + break; + case 'trial_end' : + $hook = 'woocommerce_scheduled_subscription_trial_end'; + break; + case 'end' : + // End dates may need either an expiration or end of prepaid term hook, depending on the status + if ( $subscription->has_status( 'cancelled' ) ) { + $hook = 'woocommerce_scheduled_subscription_end_of_prepaid_term'; + } elseif ( $subscription->has_status( 'active' ) ) { + $hook = 'woocommerce_scheduled_subscription_expiration'; + } + break; + } + + return apply_filters( 'woocommerce_subscriptions_scheduled_action_hook', $hook, $date_type ); + } +} diff --git a/includes/class-wcs-api.php b/includes/class-wcs-api.php new file mode 100644 index 0000000..5f9ee48 --- /dev/null +++ b/includes/class-wcs-api.php @@ -0,0 +1,43 @@ +logger = new WC_Logger(); + } + + /** + * Wrapper function around WC_Logger->log + * + * @param string $message Message to log + */ + public function log( $message ) { + if ( defined( 'WCS_DEBUG' ) && WCS_DEBUG ) { + $this->logger->add( 'wcs-cache', $message ); + } + } + + /** + * Wrapper function around our cache library. + * + * @param string $key The key to cache the data with + * @param string|array $callback name of function, or array of class - method that fetches the data + * @param array $params arguments passed to $callback + * + * @return bool|mixed + */ + public function cache_and_get( $key, $callback, $params = array(), $expires = 0 ) { + $expires = absint( $expires ); + + $transient = tlc_transient( $key ) + ->updates_with( $callback, $params ); + + if ( $expires ) { + $transient->expires_in( $expires ); + } + + return $transient->get(); + } + + /** + * Clearing for orders / subscriptions with sanitizing bits + * + * @param $post_id integer the ID of an order / subscription + */ + public function purge_subscription_cache_on_update( $post_id ) { + $post_type = get_post_type( $post_id ); + + if ( 'shop_subscription' !== $post_type && 'shop_order' !== $post_type ) { + return; + } + + if ( 'shop_subscription' === $post_type ) { + $this->log( 'ID is subscription, calling wcs_clear_related_order_cache for ' . $post_id ); + + $this->wcs_clear_related_order_cache( $post_id ); + } else { + + $this->log( 'ID is order, getting subscription.' ); + + $subscription = wcs_get_subscriptions_for_order( $post_id ); + + if ( empty( $subscription ) ) { + $this->log( 'No sub for this ID: ' . $post_id ); + return; + } + $subscription = array_shift( $subscription ); + + $this->log( 'Got subscription, calling wcs_clear_related_order_cache for ' . $subscription->id ); + + $this->wcs_clear_related_order_cache( $subscription->id ); + } + } + + /** + * Clearing cache when a post is deleted + * + * @param $post_id integer the ID of a post + */ + public function purge_delete( $post_id ) { + if ( 'shop_order' !== get_post_type( $post_id ) ) { + return; + } + + $linked_subscription = get_post_meta( $post_id, '_subscription_renewal', false ); + + // don't call this if there's nothing to call on + if ( $linked_subscription ) { + $this->log( 'Calling purge from ' . current_filter() . ' on ' . $linked_subscription[0] ); + $this->purge_subscription_cache_on_update( $linked_subscription[0] ); + } + } + + /** + * When the _subscription_renewal metadata is added / deleted / updated on the Order, we need to initiate cache invalidation for both the new + * value of the meta ($_meta_value), and the object it's being added to: $object_id. + * + * @param $meta_id integer the ID of the meta in the meta table + * @param $object_id integer the ID of the post we're updating on + * @param $meta_key string the meta_key in the table + * @param $_meta_value mixed the value we're deleting / adding / updating + */ + public function purge_from_metadata( $meta_id, $object_id, $meta_key, $_meta_value ) { + if ( '_subscription_renewal' !== $meta_key || 'shop_order' !== get_post_type( $object_id ) ) { + return; + } + + $this->log( 'Calling purge from ' . current_filter() . ' on object ' . $object_id . ' and meta value ' . $_meta_value . ' due to _subscription_renewal meta.' ); + + $this->purge_subscription_cache_on_update( $_meta_value ); + $this->purge_subscription_cache_on_update( $object_id ); + } + + /** + * Wrapper function to clear cache that relates to related orders + * + * @param null $id + */ + public function wcs_clear_related_order_cache( $id = null ) { + // if nothing was passed in, there's nothing to delete + if ( null === $id ) { + return; + } + + // if it's not a Subscription, we don't deal with it + if ( is_object( $id ) && $id instanceof WC_Subscription ) { + $id = $id->id; + } elseif ( is_numeric( $id ) ) { + $id = absint( $id ); + } else { + return; + } + + $key = tlc_transient( 'wcs-related-orders-to-' . $id )->key; + + $this->log( 'In the clearing, key being purged is this: ' . "\n\n{$key}\n\n" ); + + $this->delete_cached( $key ); + } + + /** + * Delete cached data with key + * + * @param string $key Key that needs deleting + */ + public function delete_cached( $key ) { + if ( ! is_string( $key ) || empty( $key ) ) { + return; + } + // have to do this manually for now + delete_transient( 'tlc__' . $key ); + delete_transient( 'tlc_up__' . $key ); + } +} diff --git a/includes/class-wcs-cart-initial-payment.php b/includes/class-wcs-cart-initial-payment.php new file mode 100644 index 0000000..4db7fa1 --- /dev/null +++ b/includes/class-wcs-cart-initial-payment.php @@ -0,0 +1,116 @@ +setup_hooks(); + } + + /** + * Setup the cart for paying for a delayed initial payment for a subscription. + * + * @since 2.0 + */ + public function maybe_setup_cart() { + global $wp; + + if ( isset( $_GET['pay_for_order'] ) && isset( $_GET['key'] ) && isset( $wp->query_vars['order-pay'] ) ) { + + // Pay for existing order + $order_key = $_GET['key']; + $order_id = ( isset( $wp->query_vars['order-pay'] ) ) ? $wp->query_vars['order-pay'] : absint( $_GET['order_id'] ); + $order = wc_get_order( $wp->query_vars['order-pay'] ); + + if ( $order->order_key == $order_key && $order->has_status( array( 'pending', 'failed' ) ) && ! wcs_order_contains_subscription( $order, array( 'renewal', 'resubscribe' ) ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ); + + if ( get_current_user_id() !== $order->get_user_id() ) { + + wc_add_notice( __( 'That doesn\'t appear to be your order.', 'woocommerce-subscriptions' ), 'error' ); + + wp_safe_redirect( get_permalink( wc_get_page_id( 'myaccount' ) ) ); + exit; + + } elseif ( ! empty( $subscriptions ) ) { + + // Setup cart with all the original order's line items + $this->setup_cart( $order, array( + 'order_id' => $order_id, + ) ); + + WC()->session->set( 'order_awaiting_payment', $order_id ); + + // Set cart hash for orders paid in WC >= 2.6 + $this->set_cart_hash( $order_id ); + + wp_safe_redirect( WC()->cart->get_checkout_url() ); + exit; + } + } + } + } + + /** + * Checks the cart to see if it contains an initial payment item. + * + * @return bool | Array The cart item containing the initial payment, else false. + * @since 2.0.13 + */ + protected function cart_contains() { + + $contains_initial_payment = false; + + if ( ! empty( WC()->cart->cart_contents ) ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item[ $this->cart_item_key ] ) ) { + $contains_initial_payment = $cart_item; + break; + } + } + } + + return apply_filters( 'wcs_cart_contains_initial_payment', $contains_initial_payment ); + } + + /** + * Get the order object used to construct the initial payment cart. + * + * @param Array The initial payment cart item. + * @return WC_Order | The order object + * @since 2.0.13 + */ + protected function get_order( $cart_item = '' ) { + $order = false; + + if ( empty( $cart_item ) ) { + $cart_item = $this->cart_contains(); + } + + if ( false !== $cart_item && isset( $cart_item[ $this->cart_item_key ] ) ) { + $order = wc_get_order( $cart_item[ $this->cart_item_key ]['order_id'] ); + } + + return $order; + } + +} +new WCS_Cart_Initial_Payment(); diff --git a/includes/class-wcs-cart-renewal.php b/includes/class-wcs-cart-renewal.php new file mode 100644 index 0000000..a8a34ea --- /dev/null +++ b/includes/class-wcs-cart-renewal.php @@ -0,0 +1,814 @@ +setup_hooks(); + + // Set URL parameter for manual subscription renewals + add_filter( 'woocommerce_get_checkout_payment_url', array( &$this, 'get_checkout_payment_url' ), 10, 2 ); + + // Remove order action buttons from the My Account page + add_filter( 'woocommerce_my_account_my_orders_actions', array( &$this, 'filter_my_account_my_orders_actions' ), 10, 2 ); + + // Update customer's address on the subscription if it is changed during renewal + add_filter( 'woocommerce_checkout_update_customer_data', array( &$this, 'maybe_update_subscription_customer_data' ), 10, 2 ); + + // When a failed renewal order is paid for via checkout, make sure WC_Checkout::create_order() preserves its "failed" status until it is paid + add_filter( 'woocommerce_default_order_status', array( &$this, 'maybe_preserve_order_status' ) ); + } + + /** + * Bootstraps the class and hooks required actions & filters. + * + * @since 2.0 + */ + public function setup_hooks() { + + // Make sure renewal meta data persists between sessions + add_filter( 'woocommerce_get_cart_item_from_session', array( &$this, 'get_cart_item_from_session' ), 10, 3 ); + add_action( 'woocommerce_cart_loaded_from_session', array( &$this, 'cart_items_loaded_from_session' ), 10 ); + + // Make sure fees are added to the cart + add_action( 'woocommerce_cart_calculate_fees', array( &$this, 'maybe_add_fees' ), 10, 1 ); + + // Allow renewal of limited subscriptions + add_filter( 'woocommerce_subscription_is_purchasable', array( &$this, 'is_purchasable' ), 12, 2 ); + add_filter( 'woocommerce_subscription_variation_is_purchasable', array( &$this, 'is_purchasable' ), 12, 2 ); + + // Check if a user is requesting to create a renewal order for a subscription, needs to happen after $wp->query_vars are set + add_action( 'template_redirect', array( &$this, 'maybe_setup_cart' ), 100 ); + + // Apply renewal discounts as pseudo coupons + add_action( 'wcs_after_renewal_setup_cart_subscription', array( &$this, 'maybe_setup_discounts' ), 10, 1 ); + add_filter( 'woocommerce_get_shop_coupon_data', array( &$this, 'renewal_coupon_data' ), 10, 2 ); + add_action( 'wcs_before_renewal_setup_cart_subscriptions', array( &$this, 'clear_coupons' ), 10 ); + + add_action( 'woocommerce_remove_cart_item', array( &$this, 'maybe_remove_items' ), 10, 1 ); + add_action( 'woocommerce_before_cart_item_quantity_zero', array( &$this, 'maybe_remove_items' ), 10, 1 ); + add_action( 'woocommerce_cart_emptied', array( &$this, 'clear_coupons' ), 10 ); + + add_filter( 'woocommerce_cart_item_removed_title', array( &$this, 'items_removed_title' ), 10, 2 ); + + add_action( 'woocommerce_cart_item_restored', array( &$this, 'maybe_restore_items' ), 10, 1 ); + + // Use original order price when resubscribing to products with addons (to ensure the adds on prices are included) + add_filter( 'woocommerce_product_addons_adjust_price', array( &$this, 'product_addons_adjust_price' ), 10, 2 ); + } + + /** + * Check if a payment is being made on a renewal order from 'My Account'. If so, + * redirect the order into a cart/checkout payment flow so that the customer can + * choose payment method, apply discounts set shipping and pay for the order. + * + * @since 2.0 + */ + public function maybe_setup_cart() { + + global $wp; + + if ( isset( $_GET['pay_for_order'] ) && isset( $_GET['key'] ) && isset( $wp->query_vars['order-pay'] ) ) { + + // Pay for existing order + $order_key = $_GET['key']; + $order_id = ( isset( $wp->query_vars['order-pay'] ) ) ? $wp->query_vars['order-pay'] : absint( $_GET['order_id'] ); + $order = wc_get_order( $wp->query_vars['order-pay'] ); + + if ( $order->order_key == $order_key && $order->has_status( array( 'pending', 'failed' ) ) && wcs_order_contains_renewal( $order ) ) { + + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order ); + + do_action( 'wcs_before_renewal_setup_cart_subscriptions', $subscriptions, $order ); + + foreach ( $subscriptions as $subscription ) { + + do_action( 'wcs_before_renewal_setup_cart_subscription', $subscription, $order ); + + // Add the existing subscription items to the cart + $this->setup_cart( $subscription, array( + 'subscription_id' => $subscription->id, + 'renewal_order_id' => $order_id, + ) ); + + do_action( 'wcs_after_renewal_setup_cart_subscription', $subscription, $order ); + } + + do_action( 'wcs_after_renewal_setup_cart_subscriptions', $subscriptions, $order ); + + if ( WC()->cart->cart_contents_count != 0 ) { + // Store renewal order's ID in session so it can be re-used after payment + WC()->session->set( 'order_awaiting_payment', $order_id ); + + // Set cart hash for orders paid in WC >= 2.6 + $this->set_cart_hash( $order_id ); + } + + wp_safe_redirect( WC()->cart->get_checkout_url() ); + exit; + } + } + } + + /** + * Set up cart item meta data for a to complete a subscription renewal via the cart. + * + * @since 2.0 + */ + protected function setup_cart( $subscription, $cart_item_data ) { + + WC()->cart->empty_cart( true ); + $success = true; + + foreach ( $subscription->get_items() as $item_id => $line_item ) { + // Load all product info including variation data + $product_id = (int) apply_filters( 'woocommerce_add_to_cart_product_id', $line_item['product_id'] ); + $quantity = (int) $line_item['qty']; + $variation_id = (int) $line_item['variation_id']; + $variations = array(); + + foreach ( $line_item['item_meta'] as $meta_name => $meta_value ) { + if ( taxonomy_is_product_attribute( $meta_name ) ) { + $variations[ $meta_name ] = $meta_value[0]; + } elseif ( meta_is_product_attribute( $meta_name, $meta_value[0], $product_id ) ) { + $variations[ $meta_name ] = $meta_value[0]; + } + } + + $product = wc_get_product( $line_item['product_id'] ); + + // The notice displayed when a subscription product has been deleted and the custoemr attempts to manually renew or make a renewal payment for a failed recurring payment for that product/subscription + // translators: placeholder is an item name + $product_deleted_error_message = apply_filters( 'woocommerce_subscriptions_renew_deleted_product_error_message', __( 'The %s product has been deleted and can no longer be renewed. Please choose a new product or contact us for assistance.', 'woocommerce-subscriptions' ) ); + + // Display error message for deleted products + if ( false === $product ) { + + wc_add_notice( sprintf( $product_deleted_error_message, $line_item['name'] ), 'error' ); + + // Make sure we don't actually need the variation ID (if the product was a variation, it will have a variation ID; however, if the product has changed from a simple subscription to a variable subscription, there will be no variation_id) + } elseif ( $product->is_type( array( 'variable-subscription' ) ) && ! empty( $line_item['variation_id'] ) ) { + + $variation = wc_get_product( $variation_id ); + + // Display error message for deleted product variations + if ( false === $variation ) { + wc_add_notice( sprintf( $product_deleted_error_message, $line_item['name'] ), 'error' ); + } + } + + if ( wcs_is_subscription( $subscription ) ) { + $cart_item_data['subscription_line_item_id'] = $item_id; + } + + $cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, $variations, apply_filters( 'woocommerce_order_again_cart_item_data', array( $this->cart_item_key => $cart_item_data ), $line_item, $subscription ) ); + $success = $success && (bool) $cart_item_key; + } + + // If a product linked to a subscription failed to be added to the cart prevent partially paying for the order by removing all cart items. + if ( ! $success && wcs_is_subscription( $subscription ) ) { + // translators: %s is subscription's number + wc_add_notice( sprintf( esc_html__( 'Subscription #%s has not been added to the cart.', 'woocommerce-subscriptions' ), $subscription->get_order_number() ) , 'error' ); + WC()->cart->empty_cart( true ); + } + + do_action( 'woocommerce_setup_cart_for_' . $this->cart_item_key, $subscription, $cart_item_data ); + } + + /** + * Check if a renewal order subscription has any coupons applied and if so add pseudo renewal coupon equivalents to ensure the discount is still applied + * + * @param object $subscription subscription + * @since 2.0.10 + */ + public function maybe_setup_discounts( $subscription ) { + + if ( wcs_is_subscription( $subscription ) ) { + + $used_coupons = $subscription->get_used_coupons(); + + // Add any used coupon discounts to the cart (as best we can) using our pseudo renewal coupons + if ( ! empty( $used_coupons ) ) { + + $coupon_items = $subscription->get_items( 'coupon' ); + + foreach ( $coupon_items as $coupon_item ) { + + $coupon = new WC_Coupon( $coupon_item['name'] ); + + $coupon_code = ''; + + // If the coupon still exists we can use the existing/available coupon properties + if ( true === $coupon->exists ) { + + // But we only want to handle recurring coupons that have been applied to the subscription + if ( in_array( $coupon->type, array( 'recurring_percent', 'recurring_fee' ) ) ) { + + // Set the coupon type to be a renewal equivalent for correct validation and calculations + if ( 'recurring_percent' == $coupon->type ) { + $coupon->type = 'renewal_percent'; + } elseif ( 'recurring_fee' == $coupon->type ) { + $coupon->type = 'renewal_fee'; + } + + // Adjust coupon code to reflect that it is being applied to a renewal + $coupon_code = $coupon->code; + } + } else { + + // If the coupon doesn't exist we can only really apply the discount amount we know about - so we'll apply a cart style pseudo coupon and then set the amount + $coupon->type = 'renewal_cart'; + $coupon->amount = $coupon_item['item_meta']['discount_amount']['0']; + + // Adjust coupon code to reflect that it is being applied to a renewal + $coupon_code = $coupon->code; + } + + // Now that we have a coupon we know we want to apply + if ( ! empty( $coupon_code ) ) { + + // Set renewal order products as the product ids on the coupon + if ( ! WC_Subscriptions::is_woocommerce_pre( '2.5' ) ) { + $coupon->product_ids = $this->get_products( $subscription ); + } + + // Store the coupon info for later + $this->store_coupon( $subscription->id, $coupon ); + + // Add the coupon to the cart - the actually coupon values / data are grabbed when needed later + if ( WC()->cart && ! WC()->cart->has_discount( $coupon_code ) ) { + WC()->cart->add_discount( $coupon_code ); + } + } + } + // If there are no coupons but there is still a discount (i.e. it might have been manually added), we need to account for that as well + } elseif ( ! empty( $subscription->cart_discount ) ) { + + $coupon = new WC_Coupon( 'discount_renewal' ); + + // Apply our cart style pseudo coupon and the set the amount + $coupon->type = 'renewal_cart'; + $coupon->amount = $subscription->cart_discount; + + // Set renewal order products as the product ids on the coupon + if ( ! WC_Subscriptions::is_woocommerce_pre( '2.5' ) ) { + $coupon->product_ids = $this->get_products( $subscription ); + } + + // Store the coupon info for later + $this->store_coupon( $subscription->id, $coupon ); + + // Add the coupon to the cart + if ( WC()->cart && ! WC()->cart->has_discount( 'discount_renewal' ) ) { + WC()->cart->add_discount( 'discount_renewal' ); + } + } + } + } + + /** + * Does some housekeeping. Fires after the items have been passed through the get items from session filter. Because + * that filter is not good for removing cart items, we need to work around that by doing it later, in the cart + * loaded from session action. + * + * This checks cart items whether underlying subscriptions / renewal orders they depend exist. If not, they are + * removed from the cart. + * + * @param $cart WC_Cart the one we got from session + */ + public function cart_items_loaded_from_session( $cart ) { + $removed_count_subscription = $removed_count_order = 0; + + foreach ( $cart->cart_contents as $key => $item ) { + if ( isset( $item[ $this->cart_item_key ]['subscription_id'] ) && ! wcs_is_subscription( $item[ $this->cart_item_key ]['subscription_id'] ) ) { + $cart->remove_cart_item( $key ); + $removed_count_subscription++; + continue; + } + + if ( isset( $item[ $this->cart_item_key ]['renewal_order_id'] ) && ! 'shop_order' == get_post_type( $item[ $this->cart_item_key ]['renewal_order_id'] ) ) { + $cart->remove_cart_item( $key ); + $removed_count_order++; + continue; + } + } + + if ( $removed_count_subscription ) { + $error_message = esc_html( _n( 'We couldn\'t find the original subscription for an item in your cart. The item was removed.', 'We couldn\'t find the original subscriptions for items in your cart. The items were removed.', $removed_count_subscription, 'woocommerce-subscriptions' ) ); + if ( ! wc_has_notice( $error_message, 'notice' ) ) { + wc_add_notice( $error_message, 'notice' ); + } + } + + if ( $removed_count_order ) { + $error_message = esc_html( _n( 'We couldn\'t find the original renewal order for an item in your cart. The item was removed.', 'We couldn\'t find the original renewal orders for items in your cart. The items were removed.', $removed_count_order, 'woocommerce-subscriptions' ) ); + if ( ! wc_has_notice( $error_message, 'notice' ) ) { + wc_add_notice( $error_message, 'notice' ); + } + } + } + + /** + * Restore renewal flag when cart is reset and modify Product object with renewal order related info + * + * @since 2.0 + */ + public function get_cart_item_from_session( $cart_item_session_data, $cart_item, $key ) { + + if ( isset( $cart_item[ $this->cart_item_key ]['subscription_id'] ) ) { + $cart_item_session_data[ $this->cart_item_key ] = $cart_item[ $this->cart_item_key ]; + + $_product = $cart_item_session_data['data']; + + // Need to get the original subscription price, not the current price + $subscription = wcs_get_subscription( $cart_item[ $this->cart_item_key ]['subscription_id'] ); + + if ( $subscription ) { + $subscription_items = $subscription->get_items(); + $item_to_renew = $subscription_items[ $cart_item_session_data[ $this->cart_item_key ]['subscription_line_item_id'] ]; + + $price = $item_to_renew['line_subtotal']; + + if ( wc_prices_include_tax() ) { + $base_tax_rates = WC_Tax::get_base_tax_rates( $_product->tax_class ); + $base_taxes_on_item = WC_Tax::calc_tax( $price, $base_tax_rates, false, false ); + $price += array_sum( $base_taxes_on_item ); + } + + $_product->price = $price / $item_to_renew['qty']; + + // Don't carry over any sign up fee + $_product->subscription_sign_up_fee = 0; + + $_product->post->post_title = apply_filters( 'woocommerce_subscriptions_renewal_product_title', $_product->get_title(), $_product ); + + // Make sure the same quantity is renewed + $cart_item_session_data['quantity'] = $item_to_renew['qty']; + } + } + + return $cart_item_session_data; + } + + /** + * When completing checkout for a subscription renewal, update the address on the subscription to use + * the shipping/billing address entered in case it has changed since the subscription was first created. + * + * @since 2.0 + */ + public function maybe_update_subscription_customer_data( $update_customer_data, $checkout_object ) { + + $cart_renewal_item = $this->cart_contains(); + + if ( false !== $cart_renewal_item ) { + + $subscription = wcs_get_subscription( $cart_renewal_item[ $this->cart_item_key ]['subscription_id'] ); + + $billing_address = array(); + if ( $checkout_object->checkout_fields['billing'] ) { + foreach ( array_keys( $checkout_object->checkout_fields['billing'] ) as $field ) { + $field_name = str_replace( 'billing_', '', $field ); + $billing_address[ $field_name ] = $checkout_object->get_posted_address_data( $field_name ); + } + } + + $shipping_address = array(); + if ( $checkout_object->checkout_fields['shipping'] ) { + foreach ( array_keys( $checkout_object->checkout_fields['shipping'] ) as $field ) { + $field_name = str_replace( 'shipping_', '', $field ); + $shipping_address[ $field_name ] = $checkout_object->get_posted_address_data( $field_name, 'shipping' ); + } + } + + $subscription->set_address( $billing_address, 'billing' ); + $subscription->set_address( $shipping_address, 'shipping' ); + } + + return $update_customer_data; + } + + /** + * If a product is being marked as not purchasable because it is limited and the customer has a subscription, + * but the current request is to resubscribe to the subscription, then mark it as purchasable. + * + * @since 2.0 + * @return bool + */ + public function is_purchasable( $is_purchasable, $product ) { + + // If the product is being set as not-purchasable by Subscriptions (due to limiting) + if ( false === $is_purchasable && false === WC_Subscriptions_Product::is_purchasable( $is_purchasable, $product ) ) { + + // Adding to cart from the product page or paying for a renewal + if ( isset( $_GET[ $this->cart_item_key ] ) || isset( $_GET['subscription_renewal'] ) || $this->cart_contains() ) { + + $is_purchasable = true; + + } else if ( WC()->session->cart ) { + + foreach ( WC()->session->cart as $cart_item_key => $cart_item ) { + + if ( $product->id == $cart_item['product_id'] && isset( $cart_item['subscription_renewal'] ) ) { + $is_purchasable = true; + break; + } + } + } + } + + return $is_purchasable; + } + + /** + * Flag payment of manual renewal orders via an extra URL param. + * + * This is particularly important to ensure renewals of limited subscriptions can be completed. + * + * @since 2.0 + */ + public function get_checkout_payment_url( $pay_url, $order ) { + + if ( wcs_order_contains_renewal( $order ) ) { + $pay_url = add_query_arg( array( $this->cart_item_key => 'true' ), $pay_url ); + } + + return $pay_url; + } + + /** + * Customise which actions are shown against a subscription renewal order on the My Account page. + * + * @since 2.0 + */ + public function filter_my_account_my_orders_actions( $actions, $order ) { + + if ( wcs_order_contains_renewal( $order ) ) { + + unset( $actions['cancel'] ); + + // If the subscription has been deleted or reactivated some other way, don't support payment on the order + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order ); + + foreach ( $subscriptions as $subscription ) { + if ( empty( $subscription ) || ! $subscription->has_status( array( 'on-hold', 'pending' ) ) ) { + unset( $actions['pay'] ); + break; + } + } + } + + return $actions; + } + + /** + * When a failed renewal order is being paid for via checkout, make sure WC_Checkout::create_order() preserves its + * status as 'failed' until it is paid. By default, it will always set it to 'pending', but we need it left as 'failed' + * so that we can correctly identify the status change in @see self::maybe_change_subscription_status(). + * + * @param string Default order status for orders paid for via checkout. Default 'pending' + * @since 2.0 + */ + public function maybe_preserve_order_status( $order_status ) { + + if ( null !== WC()->session ) { + + $order_id = absint( WC()->session->order_awaiting_payment ); + + if ( $order_id > 0 && ( $order = wc_get_order( $order_id ) ) && wcs_order_contains_renewal( $order ) && $order->has_status( 'failed' ) ) { + $order_status = 'failed'; + } + } + + return $order_status; + } + + /** + * Removes all the linked renewal/resubscribe items from the cart if a renewal/resubscribe item is removed. + * + * @param string $cart_item_key The cart item key of the item removed from the cart. + * @since 2.0 + */ + public function maybe_remove_items( $cart_item_key ) { + + if ( isset( WC()->cart->cart_contents[ $cart_item_key ][ $this->cart_item_key ]['subscription_id'] ) ) { + + $removed_item_count = 0; + $subscription_id = WC()->cart->cart_contents[ $cart_item_key ][ $this->cart_item_key ]['subscription_id']; + + foreach ( WC()->cart->cart_contents as $key => $cart_item ) { + + if ( isset( $cart_item[ $this->cart_item_key ] ) && $subscription_id == $cart_item[ $this->cart_item_key ]['subscription_id'] ) { + WC()->cart->removed_cart_contents[ $key ] = WC()->cart->cart_contents[ $key ]; + unset( WC()->cart->cart_contents[ $key ] ); + $removed_item_count++; + } + } + + //remove the renewal order flag + unset( WC()->session->order_awaiting_payment ); + + //clear renewal coupons + $this->clear_coupons(); + + if ( $removed_item_count > 1 && 'woocommerce_before_cart_item_quantity_zero' == current_filter() ) { + wc_add_notice( esc_html__( 'All linked subscription items have been removed from the cart.', 'woocommerce-subscriptions' ), 'notice' ); + } + } + } + + /** + * Checks the cart to see if it contains a subscription renewal item. + * + * @see wcs_cart_contains_renewal() + * @return bool | Array The cart item containing the renewal, else false. + * @since 2.0.10 + */ + protected function cart_contains() { + return wcs_cart_contains_renewal(); + } + + /** + * Formats the title of the product removed from the cart. Because we have removed all + * linked renewal/resubscribe items from the cart we need a product title to reflect that. + * + * @param string $product_title + * @param $cart_item + * @return string $product_title + * @since 2.0 + */ + public function items_removed_title( $product_title, $cart_item ) { + + if ( isset( $cart_item[ $this->cart_item_key ]['subscription_id'] ) ) { + $subscription = wcs_get_subscription( absint( $cart_item[ $this->cart_item_key ]['subscription_id'] ) ); + $product_title = ( count( $subscription->get_items() ) > 1 ) ? esc_html_x( 'All linked subscription items were', 'Used in WooCommerce by removed item notification: "_All linked subscription items were_ removed. Undo?" Filter for item title.', 'woocommerce-subscriptions' ) : $product_title; + } + + return $product_title; + } + + /** + * Restores all linked renewal/resubscribe items to the cart if the customer has restored one. + * + * @param string $cart_item_key The cart item key of the item being restored to the cart. + * @since 2.0 + */ + public function maybe_restore_items( $cart_item_key ) { + + if ( isset( WC()->cart->cart_contents[ $cart_item_key ][ $this->cart_item_key ]['subscription_id'] ) ) { + + $subscription_id = WC()->cart->cart_contents[ $cart_item_key ][ $this->cart_item_key ]['subscription_id']; + + foreach ( WC()->cart->removed_cart_contents as $key => $cart_item ) { + + if ( isset( $cart_item[ $this->cart_item_key ] ) && $key != $cart_item_key && $cart_item[ $this->cart_item_key ]['subscription_id'] == $subscription_id ) { + WC()->cart->cart_contents[ $key ] = WC()->cart->removed_cart_contents[ $key ]; + unset( WC()->cart->removed_cart_contents[ $key ] ); + } + } + + //restore the renewal order flag + if ( isset( WC()->cart->cart_contents[ $cart_item_key ][ $this->cart_item_key ]['renewal_order_id'] ) ) { + WC()->session->set( 'order_awaiting_payment', WC()->cart->cart_contents[ $cart_item_key ][ $this->cart_item_key ]['renewal_order_id'] ); + } + } + } + + /** + * Return our custom pseudo coupon data for renewal coupons + * + * @param array $data the coupon data + * @param string $code the coupon code that data is being requested for + * @return array the custom coupon data + * @since 2.0.10 + */ + public function renewal_coupon_data( $data, $code ) { + + if ( ! is_object( WC()->session ) ) { + return $data; + } + + $renewal_coupons = WC()->session->get( 'wcs_renewal_coupons' ); + + if ( empty( $renewal_coupons ) ) { + return $data; + } + + foreach ( $renewal_coupons as $subscription_id => $coupons ) { + + foreach ( $coupons as $coupon ) { + + // Tweak the coupon data for renewal coupons + if ( $code == $coupon->code ) { + + $data = array( + 'discount_type' => $coupon->type, + 'coupon_amount' => $coupon->amount, + 'individual_use' => ( $coupon->individual_use ) ? $coupon->individual_use : 'no', + 'product_ids' => ( $coupon->product_ids ) ? $coupon->product_ids : array(), + 'exclude_product_ids' => ( $coupon->exclude_product_ids ) ? $coupon->exclude_product_ids : array(), + 'usage_limit' => '', + 'usage_count' => '', + 'expiry_date' => '', + 'free_shipping' => ( $coupon->free_shipping ) ? $coupon->free_shipping : '', + 'product_categories' => ( $coupon->product_categories ) ? $coupon->product_categories : array(), + 'exclude_product_categories' => ( $coupon->exclude_product_categories ) ? $coupon->exclude_product_categories : array(), + 'exclude_sale_items' => ( $coupon->exclude_sale_items ) ? $coupon->exclude_sale_items : 'no', + 'minimum_amount' => ( $coupon->minimum_amount ) ? $coupon->minimum_amount : '', + 'maximum_amount' => ( $coupon->maximum_amount ) ? $coupon->maximum_amount : '', + 'customer_email' => ( $coupon->customer_email ) ? $coupon->customer_email : array(), + ); + } + } + } + return $data; + } + + /** + * Get original products for a renewal order - so that we can ensure renewal coupons are only applied to those + * + * @param object $subscription subscription + * @return array $product_ids an array of product ids on a subscription renewal order + * @since 2.0.10 + */ + protected function get_products( $subscription ) { + + $product_ids = array(); + + if ( wcs_is_subscription( $subscription ) ) { + foreach ( $subscription->get_items() as $item ) { + $product_id = ( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id']; + if ( ! empty( $product_id ) ) { + $product_ids[] = $product_id; + } + } + } + + return $product_ids; + } + + /** + * Store renewal coupon information in a session variable so we can access it later when coupon data is being retrieved + * + * @param int $subscription_id subscription id + * @param object $coupon coupon + * @since 2.0.10 + */ + protected function store_coupon( $subscription_id, $coupon ) { + if ( ! empty( $subscription_id ) && ! empty( $coupon ) ) { + + $renewal_coupons = WC()->session->get( 'wcs_renewal_coupons', array() ); + + // Subscriptions may have multiple coupons, store coupons in array + if ( array_key_exists( $subscription_id, $renewal_coupons ) ) { + $renewal_coupons[ $subscription_id ][] = $coupon; + } else { + $renewal_coupons[ $subscription_id ] = array( $coupon ); + } + + WC()->session->set( 'wcs_renewal_coupons', $renewal_coupons ); + } + } + + /** + * Clear renewal coupons - protects against confusing customer facing notices if customers add one renewal order to the cart with a set of coupons and then decide to add another renewal order with a different set of coupons + * + * @since 2.0.10 + */ + public function clear_coupons() { + + $renewal_coupons = WC()->session->get( 'wcs_renewal_coupons' ); + + // Remove the coupons from the cart + if ( ! empty( $renewal_coupons ) ) { + foreach ( $renewal_coupons as $subscription_id => $coupons ) { + foreach ( $coupons as $coupon ) { + WC()->cart->remove_coupons( $coupon->code ); + } + } + } + + // Clear the session information we have stored + WC()->session->set( 'wcs_renewal_coupons', array() ); + } + + /** + * Add order/subscription fee line items to the cart when a renewal order, initial order or resubscribe is in the cart. + * + * @param WC_Cart $cart + * @since 2.0.13 + */ + public function maybe_add_fees( $cart ) { + + if ( $cart_item = $this->cart_contains() ) { + + $order = $this->get_order( $cart_item ); + + if ( $order instanceof WC_Order ) { + foreach ( $order->get_fees() as $fee ) { + $cart->add_fee( $fee['name'], $fee['line_total'], abs( $fee['line_tax'] ) > 0, $fee['tax_class'] ); + } + } + } + } + + /** + * When restoring the cart from the session, if the cart item contains addons, as well as + * a renewal or resubscribe, do not adjust the price because the original order's price will + * be used, and this includes the addons amounts. + * + * @since 2.0 + */ + public function product_addons_adjust_price( $adjust_price, $cart_item ) { + + if ( true === $adjust_price && isset( $cart_item[ $this->cart_item_key ] ) ) { + $adjust_price = false; + } + + return $adjust_price; + } + + /** + * Get the order object used to construct the renewal cart. + * + * @param Array The renewal cart item. + * @return WC_Order | The order object + * @since 2.0.13 + */ + protected function get_order( $cart_item = '' ) { + $order = false; + + if ( empty( $cart_item ) ) { + $cart_item = $this->cart_contains(); + } + + if ( false !== $cart_item && isset( $cart_item[ $this->cart_item_key ] ) ) { + $order = wc_get_order( $cart_item[ $this->cart_item_key ]['renewal_order_id'] ); + } + + return $order; + } + + /** + * Before allowing payment on an order awaiting payment via checkout, WC >= 2.6 validates + * order items haven't changed by checking for a cart hash on the order, so we need to set + * that here. @see WC_Checkout::create_order() + * + * @since 2.0.14 + */ + protected function set_cart_hash( $order_id ) { + update_post_meta( $order_id, '_cart_hash', md5( json_encode( wc_clean( WC()->cart->get_cart_for_session() ) ) . WC()->cart->total ) ); + } + + /* Deprecated */ + + /** + * For subscription renewal via cart, use original order discount + * + * @since 2.0 + */ + public function set_renewal_discounts( $cart ) { + _deprecated_function( __METHOD__, '2.0.10', 'Applying original subscription discounts to renewals via cart are now handled within ' . __CLASS__ .'::maybe_setup_cart()' ); + } + + /** + * For subscription renewal via cart, previously adjust item price by original order discount + * + * No longer required as of 1.3.5 as totals are calculated correctly internally. + * + * @since 2.0 + */ + public function get_discounted_price_for_renewal( $price, $cart_item, $cart ) { + _deprecated_function( __METHOD__, '2.0.10', 'No longer required as of 1.3.5 as totals are calculated correctly internally.' ); + } + + /** + * Add subscription fee line items to the cart when a renewal order or resubscribe is in the cart. + * + * @param WC_Cart $cart + * @since 2.0.10 + */ + public function maybe_add_subscription_fees( $cart ) { + _deprecated_function( __METHOD__, '2.0.13', __CLASS__ .'::maybe_add_fees()' ); + } +} +new WCS_Cart_Renewal(); diff --git a/includes/class-wcs-cart-resubscribe.php b/includes/class-wcs-cart-resubscribe.php new file mode 100644 index 0000000..e25674c --- /dev/null +++ b/includes/class-wcs-cart-resubscribe.php @@ -0,0 +1,225 @@ +setup_hooks(); + + // When a resubscribe order is created on checkout, record the resubscribe, attached after WC_Subscriptions_Checkout::process_checkout() + add_action( 'woocommerce_checkout_subscription_created', array( &$this, 'maybe_record_resubscribe' ), 10, 3 ); + } + + /** + * Checks if the current request is by a user to resubcribe to a subscription, and if it is setup a + * subscription resubcribe process via the cart for the product/variation/s that are being renewed. + * + * @since 2.0 + */ + public function maybe_setup_cart() { + global $wp; + + if ( isset( $_GET['resubscribe'] ) && isset( $_GET['_wpnonce'] ) ) { + + $subscription = wcs_get_subscription( $_GET['resubscribe'] ); + $redirect_to = get_permalink( wc_get_page_id( 'myaccount' ) ); + + if ( wp_verify_nonce( $_GET['_wpnonce'], $subscription->id ) === false ) { + + wc_add_notice( __( 'There was an error with your request to resubscribe. Please try again.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( empty( $subscription ) ) { + + wc_add_notice( __( 'That subscription does not exist. Has it been deleted?', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! current_user_can( 'subscribe_again', $subscription->id ) ) { + + wc_add_notice( __( 'That doesn\'t appear to be one of your subscriptions.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! wcs_can_user_resubscribe_to( $subscription ) ) { + + wc_add_notice( __( 'You can not resubscribe to that subscription. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), 'error' ); + + } else { + + $this->setup_cart( $subscription, array( + 'subscription_id' => $subscription->id, + ) ); + + if ( WC()->cart->get_cart_contents_count() != 0 ) { + wc_add_notice( __( 'Complete checkout to resubscribe.', 'woocommerce-subscriptions' ), 'success' ); + } + + $redirect_to = WC()->cart->get_checkout_url(); + } + + wp_safe_redirect( $redirect_to ); + exit; + + } elseif ( isset( $_GET['pay_for_order'] ) && isset( $_GET['key'] ) && isset( $wp->query_vars['order-pay'] ) ) { + + $order_id = ( isset( $wp->query_vars['order-pay'] ) ) ? $wp->query_vars['order-pay'] : absint( $_GET['order_id'] ); + $order = wc_get_order( $wp->query_vars['order-pay'] ); + $order_key = $_GET['key']; + + if ( $order->order_key == $order_key && $order->has_status( array( 'pending', 'failed' ) ) && wcs_order_contains_resubscribe( $order ) ) { + + wc_add_notice( __( 'Complete checkout to resubscribe.', 'woocommerce-subscriptions' ), 'success' ); + + $subscriptions = wcs_get_subscriptions_for_resubscribe_order( $order ); + + foreach ( $subscriptions as $subscription ) { + $this->setup_cart( $subscription, array( + 'subscription_id' => $subscription->id, + ) ); + } + + $redirect_to = WC()->cart->get_checkout_url(); + wp_safe_redirect( $redirect_to ); + exit; + } + } + } + + /** + * When creating an order at checkout, if the checkout is to resubscribe to an expired or cancelled + * subscription, make sure we record that on the order and new subscription. + * + * @since 2.0 + */ + public function maybe_record_resubscribe( $new_subscription, $order, $recurring_cart ) { + + $cart_item = $this->cart_contains( $recurring_cart ); + + if ( false !== $cart_item ) { + update_post_meta( $order->id, '_subscription_resubscribe', $cart_item[ $this->cart_item_key ]['subscription_id'], true ); + update_post_meta( $new_subscription->id, '_subscription_resubscribe', $cart_item[ $this->cart_item_key ]['subscription_id'], true ); + } + } + + /** + * Restore renewal flag when cart is reset and modify Product object with renewal order related info + * + * @since 2.0 + */ + public function get_cart_item_from_session( $cart_item_session_data, $cart_item, $key ) { + if ( isset( $cart_item[ $this->cart_item_key ]['subscription_id'] ) ) { + + // Setup the cart as if it's a renewal (as the setup process is almost the same) + $cart_item_session_data = parent::get_cart_item_from_session( $cart_item_session_data, $cart_item, $key ); + + // Need to get the original subscription price, not the current price + $subscription = wcs_get_subscription( $cart_item[ $this->cart_item_key ]['subscription_id'] ); + if ( $subscription ) { + // Make sure the original subscription terms perisist + $_product = $cart_item_session_data['data']; + $_product->subscription_period = $subscription->billing_period; + $_product->subscription_period_interval = $subscription->billing_interval; + + // And don't give another free trial period + $_product->subscription_trial_length = 0; + } + } + + return $cart_item_session_data; + } + + /** + * If a product is being marked as not purchasable because it is limited and the customer has a subscription, + * but the current request is to resubscribe to the subscription, then mark it as purchasable. + * + * @since 2.0 + * @return bool + */ + public function is_purchasable( $is_purchasable, $product ) { + + // If the product is being set as not-purchasable by Subscriptions (due to limiting) + if ( false === $is_purchasable && false === WC_Subscriptions_Product::is_purchasable( $is_purchasable, $product ) ) { + + // Validating when restoring cart from session + if ( false !== $this->cart_contains() ) { + + $resubscribe_cart_item = $this->cart_contains(); + $subscription = wcs_get_subscription( $resubscribe_cart_item['subscription_resubscribe']['subscription_id'] ); + + if ( $subscription->has_product( $product->id ) ) { + $is_purchasable = true; + } + + // Restoring cart from session, so need to check the cart in the session (wcs_cart_contains_renewal() only checks the cart) + } elseif ( isset( WC()->session->cart ) ) { + + foreach ( WC()->session->cart as $cart_item_key => $cart_item ) { + if ( $product->id == $cart_item['product_id'] && isset( $cart_item[ $this->cart_item_key ] ) ) { + $is_purchasable = true; + break; + } + } + } elseif ( isset( $_GET['resubscribe'] ) ) { // Is a request to resubscribe + + $subscription = wcs_get_subscription( absint( $_GET['resubscribe'] ) ); + + if ( false !== $subscription && $subscription->has_product( $product->id ) && wcs_can_user_resubscribe_to( $subscription ) ) { + $is_purchasable = true; + } + } + } + + return $is_purchasable; + } + + /** + * Checks the cart to see if it contains a subscription resubscribe item. + * + * @see wcs_cart_contains_resubscribe() + * @param WC_Cart $cart The cart object to search in. + * @return bool | Array The cart item containing the renewal, else false. + * @since 2.0.10 + */ + protected function cart_contains( $cart = '' ) { + return wcs_cart_contains_resubscribe( $cart ); + } + + /** + * Get the subscription object used to construct the resubscribe cart. + * + * @param Array The resubscribe cart item. + * @return WC_Subscription | The subscription object. + * @since 2.0.13 + */ + protected function get_order( $cart_item = '' ) { + $subscription = false; + + if ( empty( $cart_item ) ) { + $cart_item = $this->cart_contains(); + } + + if ( false !== $cart_item && isset( $cart_item[ $this->cart_item_key ] ) ) { + $subscription = wcs_get_subscription( $cart_item[ $this->cart_item_key ]['subscription_id'] ); + } + + return $subscription; + } +} +new WCS_Cart_Resubscribe(); diff --git a/includes/class-wcs-change-payment-method-admin.php b/includes/class-wcs-change-payment-method-admin.php new file mode 100644 index 0000000..d8c15e4 --- /dev/null +++ b/includes/class-wcs-change-payment-method-admin.php @@ -0,0 +1,171 @@ +payment_method ) ? $subscription->payment_method : ''; + $valid_payment_methods = self::get_valid_payment_methods( $subscription ); + + if ( ! $subscription->is_manual() && ! isset( $valid_payment_methods[ $payment_method ] ) ) { + $subscription_payment_gateway = WC_Subscriptions_Payment_Gateways::get_payment_gateway( $payment_method ); + + if ( false != $subscription_payment_gateway ) { + $valid_payment_methods[ $payment_method ] = $subscription_payment_gateway->title; + } + } + + echo '

    '; + + if ( count( $valid_payment_methods ) > 1 ) { + + $found_method = false; + echo ''; + echo ''; + + } elseif ( count( $valid_payment_methods ) == 1 ) { + echo '' . esc_html__( 'Payment Method', 'woocommerce-subscriptions' ) . ':
    ' . esc_html( current( $valid_payment_methods ) ); + echo ''; + echo ''; + } + + echo '

    '; + + $payment_method_table = apply_filters( 'woocommerce_subscription_payment_meta', array(), $subscription ); + + if ( is_array( $payment_method_table ) ) { + + foreach ( $payment_method_table as $payment_method_id => $payment_method_meta ) { + + echo '
    is_manual() ) ? 'style="display:none;"' : '' ) .' >'; + + foreach ( $payment_method_meta as $meta_table => $meta ) { + + foreach ( $meta as $meta_key => $meta_data ) { + + $field_id = sprintf( '_payment_method_meta[%s][%s]', $meta_table , $meta_key ); + $field_label = ( ! empty( $meta_data['label'] ) ) ? $meta_data['label'] : $meta_key ; + $field_value = ( ! empty( $meta_data['value'] ) ) ? $meta_data['value'] : null ; + $field_disabled = ( isset( $meta_data['disabled'] ) && true == $meta_data['disabled'] ) ? ' readonly' : ''; + + echo '

    '; + echo ''; + echo ''; + echo '

    '; + + } + } + + echo '
    '; + + } + } + + wp_nonce_field( 'wcs_change_payment_method_admin', '_wcsnonce' ); + + } + + /** + * Get the new payment data from POST and check the new payment method supports + * the new admin change hook. + * + * @since 2.0 + * @param $subscription WC_Subscription + */ + public static function save_meta( $subscription ) { + + if ( empty( $_POST['_wcsnonce'] ) || ! wp_verify_nonce( $_POST['_wcsnonce'], 'wcs_change_payment_method_admin' ) ) { + return; + } + + $payment_gateways = WC()->payment_gateways->payment_gateways(); + $payment_method = isset( $_POST['_payment_method'] ) ? wc_clean( $_POST['_payment_method'] ) : ''; + $payment_method_meta = apply_filters( 'woocommerce_subscription_payment_meta', array(), $subscription ); + $payment_method_meta = ( ! empty( $payment_method_meta[ $payment_method ] ) ) ? $payment_method_meta[ $payment_method ] : array(); + + $valid_payment_methods = self::get_valid_payment_methods( $subscription ); + + if ( ! isset( $valid_payment_methods[ $payment_method ] ) ) { + throw new Exception( __( 'Please choose a valid payment gateway to change to.', 'woocommerce-subscriptions' ) ); + } + + if ( ! empty( $payment_method_meta ) ) { + + foreach ( $payment_method_meta as $meta_table => &$meta ) { + + if ( ! is_array( $meta ) ) { + continue; + } + + foreach ( $meta as $meta_key => &$meta_data ) { + $meta_data['value'] = isset( $_POST['_payment_method_meta'][ $meta_table ][ $meta_key ] ) ? $_POST['_payment_method_meta'][ $meta_table ][ $meta_key ] : ''; + } + } + } + + $payment_gateway = ( 'manual' != $payment_method ) ? $payment_gateways[ $payment_method ] : ''; + + if ( ! $subscription->is_manual() && property_exists( $subscription->payment_gateway, 'id' ) && ( '' == $payment_gateway || ( $subscription->payment_gateway->id != $payment_gateway->id ) ) ) { + // Before updating to a new payment gateway make sure the subscription status is updated with the current gateway + $gateway_status = apply_filters( 'wcs_gateway_status_payment_changed', 'cancelled', $subscription, $payment_gateway ); + + WC_Subscriptions_Payment_Gateways::trigger_gateway_status_updated_hook( $subscription, $gateway_status ); + } + + $subscription->set_payment_method( $payment_gateway, $payment_method_meta ); + } + + /** + * Get a list of possible gateways that a subscription could be changed to by admins. + * + * @since 2.0 + * @param $subscription int | WC_Subscription + * @return + */ + public static function get_valid_payment_methods( $subscription ) { + + if ( ! $subscription instanceof WC_Subscription ) { + $subscription = wcs_get_subscription( $subscription ); + } + + $valid_gateways = array( 'manual' => __( 'Manual Renewal', 'woocommerce-subscriptions' ) ); + + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + + foreach ( $available_gateways as $gateway_id => $gateway ) { + + if ( $gateway->supports( 'subscription_payment_method_change_admin' ) && 'no' == get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) || ( ! $subscription->is_manual() && $gateway_id == $subscription->payment_method ) ) { + $valid_gateways[ $gateway_id ] = $gateway->get_title(); + + } + } + + return $valid_gateways; + + } + +} diff --git a/includes/class-wcs-download-handler.php b/includes/class-wcs-download-handler.php new file mode 100644 index 0000000..1e5293c --- /dev/null +++ b/includes/class-wcs-download-handler.php @@ -0,0 +1,194 @@ +id ) || wcs_order_contains_subscription( $order, 'any' ) ) ) { + $grant_access = false; + } + return $grant_access; + } + + /** + * Save the download permissions on the individual subscriptions as well as the order. Hooked into + * 'woocommerce_grant_product_download_permissions', which is strictly after the order received all the info + * it needed, so we don't need to play with priorities. + * + * @param integer $order_id the ID of the order. At this point it is guaranteed that it has files in it and that it hasn't been granted permissions before + */ + public static function save_downloadable_product_permissions( $order_id ) { + global $wpdb; + $order = wc_get_order( $order_id ); + + if ( wcs_order_contains_subscription( $order, 'any' ) ) { + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => array( 'any' ) ) ); + } else { + return; + } + + foreach ( $subscriptions as $subscription ) { + if ( sizeof( $subscription->get_items() ) > 0 ) { + foreach ( $subscription->get_items() as $item ) { + $_product = $subscription->get_product_from_item( $item ); + + if ( $_product && $_product->exists() && $_product->is_downloadable() ) { + $downloads = $_product->get_files(); + $product_id = wcs_get_canonical_product_id( $item ); + + foreach ( array_keys( $downloads ) as $download_id ) { + // grant access on subscription if it does not already exist + if ( ! $wpdb->get_var( $wpdb->prepare( "SELECT download_id FROM {$wpdb->prefix}woocommerce_downloadable_product_permissions WHERE `order_id` = %d AND `product_id` = %d AND `download_id` = '%s'", $subscription->id, $product_id, $download_id ) ) ) { + wc_downloadable_file_permission( $download_id, $product_id, $subscription, $item['qty'] ); + } + self::revoke_downloadable_file_permission( $product_id, $order_id, $order->user_id ); + } + } + } + } + update_post_meta( $subscription->id, '_download_permissions_granted', 1 ); + } + } + + /** + * Revokes download permissions from permissions table if a file has permissions on a subscription. If a product has + * multiple files, all permissions will be revoked from the original order. + * + * @param int $product_id the ID for the product (the downloadable file) + * @param int $order_id the ID for the original order + * @param int $user_id the user we're removing the permissions from + * @return boolean true on success, false on error + */ + public static function revoke_downloadable_file_permission( $product_id, $order_id, $user_id ) { + global $wpdb; + + $table = $wpdb->prefix . 'woocommerce_downloadable_product_permissions'; + + $where = array( + 'product_id' => $product_id, + 'order_id' => $order_id, + 'user_id' => $user_id, + ); + + $format = array( '%d', '%d', '%d' ); + + return $wpdb->delete( $table, $where, $format ); + } + + /** + * WooCommerce's function receives the original order ID, the item and the list of files. This does not work for + * download permissions stored on the subscription rather than the original order as the URL would have the wrong order + * key. This function takes the same parameters, but queries the database again for download ids belonging to all the + * subscriptions that were in the original order. Then for all subscriptions, it checks all items, and if the item + * passed in here is in that subscription, it creates the correct download link to be passsed to the email. + * + * @param array $files List of files already included in the list + * @param array $item An item (you get it by doing $order->get_items()) + * @param WC_Order $order The original order + * @return array List of files with correct download urls + */ + public static function get_item_downloads( $files, $item, $order ) { + global $wpdb; + + if ( wcs_order_contains_subscription( $order, 'any' ) ) { + $subscriptions = wcs_get_subscriptions_for_order( $order, array( 'order_type' => array( 'any' ) ) ); + } else { + return $files; + } + + $product_id = wcs_get_canonical_product_id( $item ); + + foreach ( $subscriptions as $subscription ) { + foreach ( $subscription->get_items() as $subscription_item ) { + if ( wcs_get_canonical_product_id( $subscription_item ) === $product_id ) { + $files = $subscription->get_item_downloads( $subscription_item ); + } + } + } + + return $files; + } + + /** + * Repairs a glitch in WordPress's save function. You cannot save a null value on update, see + * https://github.com/woothemes/woocommerce/issues/7861 for more info on this. + * + * @param integer $post_id The ID of the subscription + */ + public static function repair_permission_data( $post_id ) { + if ( absint( $post_id ) !== $post_id ) { + return; + } + + if ( 'shop_subscription' !== get_post_type( $post_id ) ) { + return; + } + + global $wpdb; + + $wpdb->query( $wpdb->prepare( " + UPDATE {$wpdb->prefix}woocommerce_downloadable_product_permissions + SET access_expires = null + WHERE order_id = %d + AND access_expires = %s + ", $post_id, '0000-00-00 00:00:00' ) ); + } + + /** + * Remove download permissions attached to a subscription when it is permenantly deleted. + * + * @since 2.0 + */ + public static function delete_subscription_permissions( $post_id ) { + global $wpdb; + + if ( 'shop_subscription' == get_post_type( $post_id ) ) { + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}woocommerce_downloadable_product_permissions WHERE order_id = %d", $post_id ) ); + } + } +} +WCS_Download_Handler::init(); diff --git a/includes/class-wcs-query.php b/includes/class-wcs-query.php new file mode 100644 index 0000000..a69e6b9 --- /dev/null +++ b/includes/class-wcs-query.php @@ -0,0 +1,169 @@ +init_query_vars(); + } + + /** + * Init query vars by loading options. + * + * @since 2.0 + */ + public function init_query_vars() { + $this->query_vars = array( + 'view-subscription' => get_option( 'woocommerce_myaccount_view_subscriptions_endpoint', 'view-subscription' ), + 'subscriptions' => get_option( 'woocommerce_myaccount_subscriptions_endpoint', 'subscriptions' ), + ); + } + + /** + * Adds endpoint breadcrumb when viewing subscription + * + * @param array $crumbs already assembled breadcrumb data + * @return array $crumbs if we're on a view-subscription page, then augmented breadcrumb data + */ + public function add_breadcrumb( $crumbs ) { + + foreach ( $this->query_vars as $key => $query_var ) { + if ( $this->is_query( $query_var ) ) { + $crumbs[] = array( $this->get_endpoint_title( $key ) ); + } + } + return $crumbs; + } + + /** + * Changes page title on view subscription page + * + * @param string $title original title + * @return string changed title + */ + public function change_endpoint_title( $title ) { + + if ( in_the_loop() ) { + foreach ( $this->query_vars as $key => $query_var ) { + if ( $this->is_query( $query_var ) ) { + $title = $this->get_endpoint_title( $key ); + } + } + } + return $title; + } + + /** + * Set the subscription page title when viewing a subscription. + * + * @since 2.0 + * @param $title + */ + public function get_endpoint_title( $endpoint ) { + global $wp; + + switch ( $endpoint ) { + case 'view-subscription': + $subscription = wcs_get_subscription( $wp->query_vars['view-subscription'] ); + $title = ( $subscription ) ? sprintf( _x( 'Subscription #%s', 'hash before order number', 'woocommerce-subscriptions' ), $subscription->get_order_number() ) : ''; + break; + case 'subscriptions': + $title = __( 'Subscriptions', 'woocommerce-subscriptions' ); + break; + default: + $title = ''; + break; + } + + return $title; + } + + /** + * Insert the new endpoint into the My Account menu. + * + * @param array $items + * @return array + */ + public function add_menu_items( $menu_items ) { + + // Add our menu item after the Orders tab if it exists, otherwise just add it to the end + if ( array_key_exists( 'orders', $menu_items ) ) { + $menu_items = wcs_array_insert_after( 'orders', $menu_items, 'subscriptions', __( 'Subscriptions', 'woocommerce-subscriptions' ) ); + } else { + $menu_items['subscriptions'] = __( 'Subscriptions', 'woocommerce-subscriptions' ); + } + + return $menu_items; + } + + /** + * Endpoint HTML content. + */ + public function endpoint_content() { + wc_get_template( 'myaccount/subscriptions.php', array(), '', plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + } + + /** + * Check if the current query is for a type we want to override. + * + * @param string $query_var the string for a query to check for + * @return bool + */ + protected function is_query( $query_var ) { + global $wp; + + if ( is_main_query() && is_page() && isset( $wp->query_vars[ $query_var ] ) ) { + $is_view_subscription_query = true; + } else { + $is_view_subscription_query = false; + } + + return apply_filters( 'wcs_query_is_query', $is_view_subscription_query, $query_var ); + } + + /** + * Fix for endpoints on the homepage + * + * Based on WC_Query->pre_get_posts(), but only applies the fix for endpoints on the homepage from it + * instead of duplicating all the code to handle the main product query. + * + * @param mixed $q query object + */ + public function pre_get_posts( $q ) { + // We only want to affect the main query + if ( ! $q->is_main_query() ) { + return; + } + + if ( $q->is_home() && 'page' === get_option( 'show_on_front' ) && absint( get_option( 'page_on_front' ) ) !== absint( $q->get( 'page_id' ) ) ) { + $_query = wp_parse_args( $q->query ); + if ( ! empty( $_query ) && array_intersect( array_keys( $_query ), array_keys( $this->query_vars ) ) ) { + $q->is_page = true; + $q->is_home = false; + $q->is_singular = true; + $q->set( 'page_id', (int) get_option( 'page_on_front' ) ); + add_filter( 'redirect_canonical', '__return_false' ); + } + } + } +} +new WCS_Query(); diff --git a/includes/class-wcs-remove-item.php b/includes/class-wcs-remove-item.php new file mode 100644 index 0000000..86e0c67 --- /dev/null +++ b/includes/class-wcs-remove-item.php @@ -0,0 +1,179 @@ + $subscription_id, 'remove_item' => $order_item_id ) ); + $remove_link = wp_nonce_url( $remove_link, $subscription_id ); + + return $remove_link; + } + + /** + * Returns the link to undo removing an item from a subscription + * + * @param int $subscription_id + * @param int $order_item_id + * @param string $base_url + * @since 2.0 + */ + public static function get_undo_remove_url( $subscription_id, $order_item_id, $base_url ) { + + $undo_link = add_query_arg( array( 'subscription_id' => $subscription_id, 'undo_remove_item' => $order_item_id ), $base_url ); + $undo_link = wp_nonce_url( $undo_link, $subscription_id ); + + return $undo_link; + } + + /** + * Process the remove or re-add a line item from a subscription request. + * + * @since 2.0 + */ + public static function maybe_remove_or_add_item_to_subscription() { + + if ( isset( $_GET['subscription_id'] ) && ( isset( $_GET['remove_item'] ) || isset( $_GET['undo_remove_item'] ) ) && isset( $_GET['_wpnonce'] ) ) { + + $subscription = ( wcs_is_subscription( $_GET['subscription_id'] ) ) ? wcs_get_subscription( $_GET['subscription_id'] ) : false; + $undo_request = ( isset( $_GET['undo_remove_item'] ) ) ? true : false; + $item_id = ( $undo_request ) ? $_GET['undo_remove_item'] : $_GET['remove_item']; + + if ( false === $subscription ) { + + wc_add_notice( sprintf( _x( 'Subscription #%d does not exist.', 'hash before subscription ID', 'woocommerce-subscriptions' ), $_GET['subscription_id'] ), 'error' ); + wp_safe_redirect( wc_get_page_permalink( 'myaccount' ) ); + exit; + } + + if ( self::validate_remove_items_request( $subscription, $item_id, $undo_request ) ) { + + if ( $undo_request ) { + // handle undo request + $removed_item = WC()->session->get( 'removed_subscription_items', array() ); + + if ( ! empty( $removed_item[ $item_id ] ) && $subscription->id == $removed_item[ $item_id ] ) { + + // restore the item + wc_update_order_item( $item_id, array( 'order_item_type' => 'line_item' ) ); + unset( $removed_item[ $item_id ] ); + + WC()->session->set( 'removed_subscription_items', $removed_item ); + + // restore download permissions for this item + $line_items = $subscription->get_items(); + $line_item = $line_items[ $item_id ]; + $_product = $subscription->get_product_from_item( $line_item ); + $product_id = wcs_get_canonical_product_id( $line_item ); + + if ( $_product && $_product->exists() && $_product->is_downloadable() ) { + + $downloads = $_product->get_files(); + + foreach ( array_keys( $downloads ) as $download_id ) { + wc_downloadable_file_permission( $download_id, $product_id, $subscription, $line_item['qty'] ); + } + } + + // translators: 1$: product name, 2$: product id + $subscription->add_order_note( sprintf( _x( 'Customer added "%1$s" (Product ID: #%2$d) via the My Account page.', 'used in order note', 'woocommerce-subscriptions' ), wcs_get_line_item_name( $line_item ), $product_id ) ); + + } else { + wc_add_notice( __( 'Your request to undo your previous action was unsuccessful.', 'woocommerce-subscriptions' ) ); + } + } else { + + // handle remove item requests + WC()->session->set( 'removed_subscription_items', array( $item_id => $subscription->id ) ); + + // remove download access for the item + $line_items = $subscription->get_items(); + $line_item = $line_items[ $item_id ]; + $product_id = wcs_get_canonical_product_id( $line_item ); + + WCS_Download_Handler::revoke_downloadable_file_permission( $product_id, $subscription->id, $subscription->get_user_id() ); + + // remove the line item from subscription but preserve its data in the DB + wc_update_order_item( $item_id, array( 'order_item_type' => 'line_item_removed' ) ); + + // translators: 1$: product name, 2$: product id + $subscription->add_order_note( sprintf( _x( 'Customer removed "%1$s" (Product ID: #%2$d) via the My Account page.', 'used in order note', 'woocommerce-subscriptions' ), wcs_get_line_item_name( $line_item ), $product_id ) ); + + // translators: placeholders are 1$: item name, and, 2$: opening and, 3$: closing link tags + wc_add_notice( sprintf( __( 'You have successfully removed "%1$s" from your subscription. %2$sUndo?%3$s', 'woocommerce-subscriptions' ), $line_item['name'], '', '' ) ); + } + } + + $subscription->calculate_totals(); + wp_safe_redirect( $subscription->get_view_order_url() ); + exit; + + } + + } + + /** + * Validate the incoming request to either remove an item or add and item back to a subscription that was previously removed. + * Add an descriptive notice to the page whether or not the request was validated or not. + * + * @since 2.0 + * @param WC_Subscription $subscription + * @param int $order_item_id + * @param bool $undo_request bool + * @return bool + */ + private static function validate_remove_items_request( $subscription, $order_item_id, $undo_request = false ) { + + $subscription_items = $subscription->get_items(); + $response = false; + + if ( ! wp_verify_nonce( $_GET['_wpnonce'], $_GET['subscription_id'] ) ) { + + wc_add_notice( __( 'Security error. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! current_user_can( 'edit_shop_subscription_line_items', $subscription->id ) ) { + + wc_add_notice( __( 'You cannot modify a subscription that does not belong to you.', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! $undo_request && ! isset( $subscription_items[ $order_item_id ] ) ) { // only need to validate the order item id when removing + + wc_add_notice( __( 'You cannot remove an item that does not exist. ', 'woocommerce-subscriptions' ), 'error' ); + + } elseif ( ! $subscription->payment_method_supports( 'subscription_amount_changes' ) ) { + + wc_add_notice( __( 'The item was not removed because this Subscription\'s payment method does not support removing an item.', 'woocommerce-subscriptions' ) ); + + } else { + + $response = true; + } + + return $response; + } + +} +WCS_Remove_Item::init(); diff --git a/includes/class-wcs-template-loader.php b/includes/class-wcs-template-loader.php new file mode 100644 index 0000000..b8b796e --- /dev/null +++ b/includes/class-wcs-template-loader.php @@ -0,0 +1,45 @@ +query_vars['view-subscription'] ) && WC_Subscriptions::is_woocommerce_pre( '2.6' ) ) { + $located = wc_locate_template( 'myaccount/view-subscription.php', $template_path, plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + } + + return $located; + } + + /** + * Get the view subscription template. A post WC v2.6 compatible version of @see WCS_Template_Loader::add_view_subscription_template() + * + * @since 2.0.17 + */ + public static function get_view_subscription_template() { + wc_get_template( 'myaccount/view-subscription.php', array(), '', plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' ); + } +} +WCS_Template_Loader::init(); diff --git a/includes/class-wcs-user-change-status-handler.php b/includes/class-wcs-user-change-status-handler.php new file mode 100644 index 0000000..fbbdad0 --- /dev/null +++ b/includes/class-wcs-user-change-status-handler.php @@ -0,0 +1,115 @@ +get_view_order_url() ); + exit; + } + } + } + + /** + * Change the status of a subscription and show a notice to the user if there was an issue. + * + * @since 2.0 + */ + public static function change_users_subscription( $subscription, $new_status ) { + $subscription = ( ! is_object( $subscription ) ) ? wcs_get_subscription( $subscription ) : $subscription; + $changed = false; + + switch ( $new_status ) { + case 'active' : + if ( ! $subscription->needs_payment() ) { + $subscription->update_status( $new_status ); + $subscription->add_order_note( _x( 'Subscription reactivated by the subscriber from their account page.', 'order note left on subscription after user action', 'woocommerce-subscriptions' ) ); + WC_Subscriptions::add_notice( _x( 'Your subscription has been reactivated.', 'Notice displayed to user confirming their action.', 'woocommerce-subscriptions' ), 'success' ); + $changed = true; + } else { + WC_Subscriptions::add_notice( __( 'You can not reactivate that subscription until paying to renew it. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), 'error' ); + } + break; + case 'on-hold' : + if ( wcs_can_user_put_subscription_on_hold( $subscription ) ) { + $subscription->update_status( $new_status ); + $subscription->add_order_note( _x( 'Subscription put on hold by the subscriber from their account page.', 'order note left on subscription after user action', 'woocommerce-subscriptions' ) ); + WC_Subscriptions::add_notice( _x( 'Your subscription has been put on hold.', 'Notice displayed to user confirming their action.', 'woocommerce-subscriptions' ), 'success' ); + $changed = true; + } else { + WC_Subscriptions::add_notice( __( 'You can not suspend that subscription - the suspension limit has been reached. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), 'error' ); + } + break; + case 'cancelled' : + $subscription->cancel_order(); + $subscription->add_order_note( _x( 'Subscription cancelled by the subscriber from their account page.', 'order note left on subscription after user action', 'woocommerce-subscriptions' ) ); + WC_Subscriptions::add_notice( _x( 'Your subscription has been cancelled.', 'Notice displayed to user confirming their action.', 'woocommerce-subscriptions' ), 'success' ); + $changed = true; + break; + } + + if ( $changed ) { + do_action( 'woocommerce_customer_changed_subscription_to_' . $new_status, $subscription ); + } + } + + /** + * Checks if the user's current request to change the status of their subscription is valid. + * + * @since 2.0 + */ + public static function validate_request( $user_id, $subscription, $new_status, $wpnonce = '' ) { + $subscription = ( ! is_object( $subscription ) ) ? wcs_get_subscription( $subscription ) : $subscription; + + if ( ! wcs_is_subscription( $subscription ) ) { + WC_Subscriptions::add_notice( __( 'That subscription does not exist. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), 'error' ); + return false; + + } elseif ( ! empty( $wpnonce ) && wp_verify_nonce( $wpnonce, $subscription->id . $subscription->get_status() ) === false ) { + WC_Subscriptions::add_notice( __( 'Security error. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), 'error' ); + return false; + + } elseif ( ! user_can( $user_id, 'edit_shop_subscription_status', $subscription->id ) ) { + WC_Subscriptions::add_notice( __( 'That doesn\'t appear to be one of your subscriptions.', 'woocommerce-subscriptions' ), 'error' ); + return false; + + } elseif ( ! $subscription->can_be_updated_to( $new_status ) ) { + // translators: placeholder is subscription's new status, translated + WC_Subscriptions::add_notice( sprintf( __( 'That subscription can not be changed to %s. Please contact us if you need assistance.', 'woocommerce-subscriptions' ), wcs_get_subscription_status_name( $new_status ) ), 'error' ); + return false; + } + + return true; + } +} +WCS_User_Change_Status_Handler::init(); diff --git a/includes/class-wcs-webhooks.php b/includes/class-wcs-webhooks.php new file mode 100644 index 0000000..eccdc01 --- /dev/null +++ b/includes/class-wcs-webhooks.php @@ -0,0 +1,149 @@ +get_resource() ) { + $topic_hooks = apply_filters( 'woocommerce_subscriptions_webhook_topics', array( + 'subscription.created' => array( + 'wcs_api_subscription_created', + 'wcs_webhook_subscription_created', + 'woocommerce_process_shop_subscription_meta', + ), + 'subscription.updated' => array( + 'wc_api_subscription_updated', + 'woocommerce_subscription_status_changed', + 'wcs_webhook_subscription_updated', + 'woocommerce_process_shop_subscription_meta', + ), + 'subscription.deleted' => array( + 'woocommerce_subscription_trashed', + 'woocommerce_subscription_deleted', + 'woocommerce_api_delete_subscription', + ), + ), $webhook ); + } + + return $topic_hooks; + } + + /** + * Add Subscription topics to the Webhooks dropdown menu in when creating a new webhook. + * + * @since 2.0 + */ + public static function add_topics_admin_menu( $topics ) { + + $front_end_topics = array( + 'subscription.created' => __( ' Subscription Created', 'woocommerce-subscriptions' ), + 'subscription.updated' => __( ' Subscription Updated', 'woocommerce-subscriptions' ), + 'subscription.deleted' => __( ' Subscription Deleted', 'woocommerce-subscriptions' ), + ); + + return array_merge( $topics, $front_end_topics ); + } + + /** + * Setup payload for subscription webhook delivery. + * + * @since 2.0 + */ + public static function create_payload( $payload, $resource, $resource_id, $id ) { + + if ( 'subscription' == $resource && empty( $payload ) && wcs_is_subscription( $resource_id ) ) { + + $webhook = new WC_Webhook( $id ); + $event = $webhook->get_event(); + $current_user = get_current_user_id(); + + wp_set_current_user( $webhook->get_user_id() ); + + WC()->api->WC_API_Subscriptions->register_routes( array() ); + + $payload = WC()->api->WC_API_Subscriptions->get_subscription( $resource_id ); + + wp_set_current_user( $current_user ); + } + + return $payload; + } + + /** + * Add webhook resource for subscription. + * + * @param array $resources + * @since 2.0 + */ + public static function add_resource( $resources ) { + + $resources[] = 'subscription'; + + return $resources; + } + + /** + * Call a "subscription created" action hook with the first parameter being a subscription id so that it can be used + * for webhooks. + * + * @since 2.0 + */ + public static function add_subscription_created_callback( $subscription ) { + do_action( 'wcs_webhook_subscription_created', $subscription->id ); + } + + /** + * Call a "subscription updated" action hook with a subscription id as the first parameter to be used for webhooks payloads. + * + * @since 2.0 + */ + public static function add_subscription_updated_callback( $subscription ) { + do_action( 'wcs_webhook_subscription_updated', $subscription->id ); + } + +} +WCS_Webhooks::init(); diff --git a/includes/deprecated/class-wcs-action-deprecator.php b/includes/deprecated/class-wcs-action-deprecator.php new file mode 100644 index 0000000..9f27ec8 --- /dev/null +++ b/includes/deprecated/class-wcs-action-deprecator.php @@ -0,0 +1,128 @@ + 'old_hook' */ + protected $deprecated_hooks = array( + 'woocommerce_scheduled_subscription_payment' => 'scheduled_subscription_payment', + 'woocommerce_subscription_payment_complete' => 'processed_subscription_payment', + 'woocommerce_subscription_renewal_payment_complete' => 'processed_subscription_renewal_payment', + 'woocommerce_subscriptions_paid_for_failed_renewal_order' => 'woocommerce_subscriptions_processed_failed_renewal_order_payment', + 'woocommerce_subscriptions_pre_update_payment_method' => 'woocommerce_subscriptions_pre_update_recurring_payment_method', + 'woocommerce_subscription_payment_method_updated' => 'woocommerce_subscriptions_updated_recurring_payment_method', + 'woocommerce_subscription_failing_payment_method_updated' => 'woocommerce_subscriptions_changed_failing_payment_method', + 'woocommerce_subscription_payment_failed' => 'processed_subscription_payment_failure', + 'woocommerce_subscription_change_payment_method_via_pay_shortcode' => 'woocommerce_subscriptions_change_payment_method_via_pay_shortcode', + 'subscriptions_put_on_hold_for_order' => 'subscriptions_suspended_for_order', + 'woocommerce_subscription_status_active' => 'activated_subscription', + 'woocommerce_subscription_status_on-hold' => array( 'suspended_subscription', 'subscription_put_on-hold' ), + 'woocommerce_subscription_status_cancelled' => 'cancelled_subscription', + 'woocommerce_subscription_status_on-hold_to_active' => 'reactivated_subscription', + 'woocommerce_subscription_status_expired' => 'subscription_expired', + 'woocommerce_scheduled_subscription_trial_end' => 'subscription_trial_end', + 'woocommerce_scheduled_subscription_end_of_prepaid_term' => 'subscription_end_of_prepaid_term', + ); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * @since 2.0 + */ + public function __construct() { + parent::__construct(); + } + + /** + * Trigger the old action with the original callback parameters + * + * @since 2.0 + */ + protected function trigger_hook( $old_hook, $new_callback_args ) { + + switch ( $old_hook ) { + + // New arg spec: $subscription_id + // Old arg spec: $user_id, $subscription_key + case 'scheduled_subscription_payment' : + case 'subscription_end_of_prepaid_term' : + case 'subscription_trial_end' : + $subscription = wcs_get_subscription( $new_callback_args[0] ); + do_action( $old_hook, $subscription->get_user_id(), wcs_get_old_subscription_key( $subscription ) ); + break; + + // New arg spec: $subscription + // Old arg spec: $user_id, $subscription_key + case 'processed_subscription_payment' : + case 'processed_subscription_renewal_payment' : + case 'processed_subscription_payment_failure' : + $subscription = $new_callback_args[0]; + do_action( $old_hook, $subscription->get_user_id(), wcs_get_old_subscription_key( $subscription ) ); + break; + + // New arg spec: $renewal_order, $subscription + // Old arg spec: $subscription_key, $original_order + case 'woocommerce_subscriptions_processed_failed_renewal_order_payment' : + $renewal_order = $new_callback_args[0]; + $subscription = $new_callback_args[1]; + do_action( $old_hook, wcs_get_old_subscription_key( $subscription ), self::get_order( $subscription ) ); + break; + + // New arg spec: $subscription, $new_payment_method, $old_payment_method + // Old arg spec: $order, $subscription_key, $new_payment_method, $old_payment_method + case 'woocommerce_subscriptions_pre_update_recurring_payment_method' : + case 'woocommerce_subscriptions_updated_recurring_payment_method' : + $subscription = $new_callback_args[0]; + $new_payment_method = $new_callback_args[1]; + $old_payment_method = $new_callback_args[2]; + do_action( $old_hook, self::get_order( $subscription ), wcs_get_old_subscription_key( $subscription ), $new_payment_method, $old_payment_method ); + break; + + // New arg spec: $subscription, $renewal_order + // Old arg spec: $original_order, $renewal_order, $subscription_key + case 'woocommerce_subscriptions_changed_failing_payment_method' : + $subscription = $new_callback_args[0]; + $renewal_order = $new_callback_args[1]; + do_action( $old_hook, self::get_order( $subscription ), $renewal_order, wcs_get_old_subscription_key( $subscription ) ); + break; + + // New arg spec: $order + // Old arg spec: $order + case 'subscriptions_suspended_for_order' : + do_action( $old_hook, $new_callback_args[0] ); + break; + + // New arg spec: $subscription + // Old arg spec: $subscription_key, $order + case 'woocommerce_subscriptions_change_payment_method_via_pay_shortcode' : + $subscription = $new_callback_args[0]; + do_action( $old_hook, wcs_get_old_subscription_key( $subscription ), self::get_order( $subscription ) ); + break; + + // New arg spec: $subscription + // Old arg spec: $user_id, $subscription_key + case 'activated_subscription' : + case 'subscription_put_on-hold' : + case 'suspended_subscription' : + case 'cancelled_subscription' : + case 'reactivated_subscription' : + case 'subscription_expired' : + $subscription = $new_callback_args[0]; + do_action( $old_hook, $subscription->get_user_id(), wcs_get_old_subscription_key( $subscription ) ); + break; + } + } +} +new WCS_Action_Deprecator(); diff --git a/includes/deprecated/class-wcs-dynamic-action-deprecator.php b/includes/deprecated/class-wcs-dynamic-action-deprecator.php new file mode 100644 index 0000000..1ed5b84 --- /dev/null +++ b/includes/deprecated/class-wcs-dynamic-action-deprecator.php @@ -0,0 +1,109 @@ + 'old_hook_prefix' */ + protected $deprecated_hook_prefixes = array( + 'woocommerce_admin_changed_subscription_to_' => 'admin_changed_subscription_to_', + 'woocommerce_scheduled_subscription_payment_' => 'scheduled_subscription_payment_', + 'woocommerce_customer_changed_subscription_to_' => 'customer_changed_subscription_to_', + 'woocommerce_subscription_payment_method_updated_to_' => 'woocommerce_subscriptions_updated_recurring_payment_method_to_', + 'woocommerce_subscription_payment_method_updated_from_' => 'woocommerce_subscriptions_updated_recurring_payment_method_from_', + 'woocommerce_subscription_failing_payment_method_updated_' => 'woocommerce_subscriptions_changed_failing_payment_method_', + + // Gateway status change hooks + 'woocommerce_subscription_activated_' => array( + 'activated_subscription_', + 'reactivated_subscription_', + ), + 'woocommerce_subscription_on-hold_' => 'subscription_put_on-hold_', + 'woocommerce_subscription_cancelled_' => 'cancelled_subscription_', + 'woocommerce_subscription_expired_' => 'subscription_expired_', + ); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * We need to use the special 'all' hook here because we don't actually know the full hook names + * in advance, just their prefix. + * + * @since 2.0 + */ + public function __construct() { + parent::__construct(); + } + + /** + * Display a notice if functions are hooked to the old filter and apply the old filters args + * + * @since 2.0 + */ + protected function trigger_hook( $old_hook, $new_callback_args ) { + + if ( 0 === strpos( $old_hook, 'admin_changed_subscription_to_' ) ) { + + // New arg spec: $subscription_id + // Old arg spec: $subscription_key + $subscription = wcs_get_subscription( $new_callback_args[0] ); + do_action( $old_hook, wcs_get_old_subscription_key( $subscription ) ); + + } elseif ( 0 === strpos( $old_hook, 'scheduled_subscription_payment_' ) ) { + + // New arg spec: $amount, $renewal_order + // Old arg spec: $amount, $original_order, $product_id + $subscription = $new_callback_args[0]; + $subscriptions = wcs_get_subscriptions_for_renewal_order( $new_callback_args[1] ); + + if ( ! empty( $subscriptions ) ) { + $subscription = array_pop( $subscriptions ); + do_action( $old_hook, $new_callback_args[0], self::get_order( $subscription ), self::get_product_id( $subscription ) ); + } + } elseif ( 0 === strpos( $old_hook, 'activated_subscription_' ) || 0 === strpos( $old_hook, 'reactivated_subscription_' ) || 0 === strpos( $old_hook, 'subscription_put_on-hold_' ) || 0 === strpos( $old_hook, 'cancelled_subscription_' ) || 0 === strpos( $old_hook, 'subscription_expired_' ) ) { + + // New arg spec: $subscription + // Old arg spec: $order, $product_id + $subscription = $new_callback_args[0]; + do_action( $old_hook, self::get_order( $subscription ), self::get_product_id( $subscription ) ); + + } elseif ( 0 === strpos( $old_hook, 'customer_changed_subscription_to_' ) ) { + + // New arg spec: $subscription + // Old arg spec: $subscription_key + do_action( $old_hook, wcs_get_old_subscription_key( $new_callback_args[0] ) ); + + } elseif ( 0 === strpos( $old_hook, 'woocommerce_subscriptions_updated_recurring_payment_method_to_' ) ) { + + // New arg spec: $subscription, $old_payment_method + // Old arg spec: $order, $subscription_key, $old_payment_method + $subscription = $new_callback_args[0]; + $old_payment_method = $new_callback_args[2]; + do_action( $old_hook, self::get_order( $subscription ), wcs_get_old_subscription_key( $subscription ), $old_payment_method ); + + } elseif ( 0 === strpos( $old_hook, 'woocommerce_subscriptions_updated_recurring_payment_method_from_' ) ) { + + // New arg spec: $subscription, $new_payment_method + // Old arg spec: $order, $subscription_key, $new_payment_method + $subscription = $new_callback_args[0]; + $new_payment_method = $new_callback_args[1]; + do_action( $old_hook, self::get_order( $subscription ), wcs_get_old_subscription_key( $subscription ), $new_payment_method ); + + } elseif ( 0 === strpos( $old_hook, 'woocommerce_subscriptions_changed_failing_payment_method_' ) ) { + + // New arg spec: $subscription, $renewal_order + // Old arg spec: $original_order, $renewal_order, $subscription_key + $subscription = $new_callback_args[0]; + do_action( $old_hook, self::get_order( $subscription ), $new_callback_args[1], wcs_get_old_subscription_key( $subscription ) ); + + } + } +} +new WCS_Dynamic_Action_Deprecator(); diff --git a/includes/deprecated/class-wcs-dynamic-filter-deprecator.php b/includes/deprecated/class-wcs-dynamic-filter-deprecator.php new file mode 100644 index 0000000..cf435d2 --- /dev/null +++ b/includes/deprecated/class-wcs-dynamic-filter-deprecator.php @@ -0,0 +1,51 @@ + 'old_hook_prefix' */ + protected $deprecated_hook_prefixes = array( + 'woocommerce_can_subscription_be_updated_to_' => 'woocommerce_subscription_can_be_changed_to_', + ); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * We need to use the special 'all' hook here because we don't actually know the full hook names + * in advance, just their prefix. + * + * @since 2.0 + */ + public function __construct() { + parent::__construct(); + } + + /** + * Display a notice if functions are hooked to the old filter and apply the old filters args + * + * @since 2.0 + */ + protected function trigger_hook( $old_hook, $new_callback_args ) { + + // Return value is always the first param + $return_value = $new_callback_args[0]; + + if ( 0 === strpos( $old_hook, 'woocommerce_subscription_can_be_changed_to_' ) ) { + // New arg spec: $can_be_updated, $subscription + // Old arg spec: $can_be_changed, $subscription, $order + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, wcs_get_subscription_in_deprecated_structure( $subscription ), self::get_order( $subscription ) ); + } + + return $return_value; + } +} +new WCS_Dynamic_Filter_Deprecator(); diff --git a/includes/deprecated/class-wcs-filter-deprecator.php b/includes/deprecated/class-wcs-filter-deprecator.php new file mode 100644 index 0000000..d3259bb --- /dev/null +++ b/includes/deprecated/class-wcs-filter-deprecator.php @@ -0,0 +1,338 @@ + 'old_hook' */ + protected $deprecated_hooks = array( + + // Subscription Meta Filters + 'woocommerce_subscription_payment_failed_count' => 'woocommerce_subscription_failed_payment_count', + 'woocommerce_subscription_payment_completed_count' => 'woocommerce_subscription_completed_payment_count', + 'woocommerce_subscription_get_end_date' => 'woocommerce_subscription_expiration_date', + 'woocommerce_subscription_get_trial_end_date' => 'woocommerce_subscription_trial_expiration_date', + 'woocommerce_subscription_date_updated' => 'woocommerce_subscriptions_set_expiration_date', + 'woocommerce_subscriptions_product_expiration_date' => 'woocommerce_subscription_calculated_expiration_date', + 'woocommerce_subscription_date_updated' => 'woocommerce_subscription_set_next_payment_date', + 'woocommerce_subscription_get_last_payment_date' => 'woocommerce_subscription_last_payment_date', + 'woocommerce_subscription_calculated_next_payment_date' => 'woocommerce_subscriptions_calculated_next_payment_date', + 'woocommerce_subscription_date_updated' => 'woocommerce_subscriptions_set_trial_expiration_date', + 'wcs_subscription_statuses' => array( + 'woocommerce_subscriptions_custom_status_string', //no replacement as Subscriptions now uses wcs_get_subscription_statuses() for everything (the deprecator could use 'wc_subscription_statuses' and loop over all statuses to set it in the returned value) + 'woocommerce_subscriptions_status_string', + ), + + // Renewal Filters + 'wcs_renewal_order_items' => 'woocommerce_subscriptions_renewal_order_items', + 'wcs_renewal_order_meta_query' => 'woocommerce_subscriptions_renewal_order_meta_query', + 'wcs_renewal_order_meta' => 'woocommerce_subscriptions_renewal_order_meta', + 'wcs_renewal_order_item_name' => 'woocommerce_subscriptions_renewal_order_item_name', + 'wcs_users_resubscribe_link' => 'woocommerce_subscriptions_users_renewal_link', + 'wcs_can_user_resubscribe_to_subscription' => 'woocommerce_can_subscription_be_renewed', + 'wcs_renewal_order_created' => array( + 'woocommerce_subscriptions_renewal_order_created', // Even though 'woocommerce_subscriptions_renewal_order_created' is an action, as it is attached to a filter, we need to handle it in here + 'woocommerce_subscriptions_renewal_order_id', + ), + + // List Table Filters + 'woocommerce_subscription_list_table_actions' => 'woocommerce_subscriptions_list_table_actions', + 'woocommerce_subscription_list_table_column_status_content' => 'woocommerce_subscriptions_list_table_column_status_content', + 'woocommerce_subscription_list_table_column_content' => 'woocommerce_subscriptions_list_table_column_content', + + // User Filters + 'wcs_can_user_put_subscription_on_hold' => 'woocommerce_subscriptions_can_current_user_suspend', + 'wcs_view_subscription_actions' => 'woocommerce_my_account_my_subscriptions_actions', + 'wcs_get_users_subscriptions' => 'woocommerce_users_subscriptions', + 'wcs_users_change_status_link' => 'woocommerce_subscriptions_users_action_link', + 'wcs_user_has_subscription' => 'woocommerce_user_has_subscription', + + // Misc Filters + 'woocommerce_subscription_max_failed_payments_exceeded' => 'woocommerce_subscriptions_max_failed_payments_exceeded', + 'woocommerce_my_subscriptions_payment_method' => 'woocommerce_my_subscriptions_recurring_payment_method', + 'woocommerce_subscriptions_update_payment_via_pay_shortcode' => 'woocommerce_subscriptions_update_recurring_payment_via_pay_shortcode', + 'woocommerce_can_subscription_be_updated_to' => 'woocommerce_can_subscription_be_changed_to', + ); + + /** + * Bootstraps the class and hooks required actions & filters. + * + * @since 2.0 + */ + public function __construct() { + parent::__construct(); + } + + /** + * Trigger the old filter with the original callback parameters and make sure the return value is passed on (when possible). + * + * @since 2.0 + */ + protected function trigger_hook( $old_hook, $new_callback_args ) { + + // Return value is always the first param + $return_value = $new_callback_args[0]; + + switch ( $old_hook ) { + + // New arg spec: $subscription_statuses + // Old arg spec: $status, $subscription_key, $user_id + case 'woocommerce_subscriptions_custom_status_string' : + // Need to loop over the status and apply the old hook to each, we don't have a subscription or user for them anymore though + foreach ( $return_value as $status_key => $status_string ) { + $return_value[ $status_key ] = apply_filters( $old_hook, $status_string, '', 0 ); + } + break; + + // New arg spec: $subscription_statuses + // Old arg spec: $status_string, $status, $subscription_key, $user_id + case 'woocommerce_subscriptions_status_string' : + // Need to loop over the status and apply the old hook to each, we don't have a subscription or user for them anymore though + foreach ( $return_value as $status_key => $status_string ) { + $return_value[ $status_key ] = apply_filters( $old_hook, $status_string, $status_key, '', 0 ); + } + break; + + // New arg spec: $count, $subscription + // Old arg spec: $count, $user_id, $subscription_key + case 'woocommerce_subscription_failed_payment_count' : + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, $subscription->get_user_id(), wcs_get_old_subscription_key( $subscription ) ); + break; + + // New arg spec: $date, $subscription, $timezone + // Old arg spec: $date, $subscription_key, $user_id + case 'woocommerce_subscription_completed_payment_count' : + case 'woocommerce_subscription_expiration_date' : + case 'woocommerce_subscription_trial_expiration_date' : + case 'woocommerce_subscription_last_payment_date' : + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, wcs_get_old_subscription_key( $subscription ), $subscription->get_user_id(), 'mysql' ); + break; + + // New arg spec: $expiration_date, $product_id, $from_date + // Old arg spec: $expiration_date, $subscription_key, $user_id + case 'woocommerce_subscription_calculated_expiration_date' : + $return_value = apply_filters( $old_hook, $return_value, '', 0 ); + break; + + // New arg spec: $next_payment_date, $subscription + // Old arg spec: $next_payment_date, $order, $product_id, $type, $from_date, $from_date_arg + case 'woocommerce_subscriptions_calculated_next_payment_date' : + $subscription = $new_callback_args[1]; + $last_payment = $subscription->get_date( 'last_payment' ); + $return_value = apply_filters( $old_hook, $return_value, self::get_order( $subscription ), self::get_product_id( $subscription ), 'mysql', $last_payment, $last_payment ); + break; + + // New arg spec: $subscription, $date_type, $datetime + // Old arg spec: $is_set, $expiration_date, $subscription_key, $user_id + case 'woocommerce_subscription_set_next_payment_date' : + case 'woocommerce_subscriptions_set_trial_expiration_date' : + case 'woocommerce_subscriptions_set_expiration_date' : + + $subscription = $new_callback_args[0]; + $date_type = $new_callback_args[1]; + + if ( ( 'next_payment' == $date_type && in_array( $old_hook, array( 'woocommerce_subscriptions_set_trial_expiration_date', 'woocommerce_subscription_set_next_payment_date' ) ) ) || ( 'end_date' == $date_type && 'woocommerce_subscriptions_set_expiration_date' == $old_hook ) ) { + // Here the old return value was a boolean where as now there is no equivalent filter, so we apply the filter to the action (which is only triggered when the old filter's value would have been true) and ignore the return value + apply_filters( $old_hook, true, wcs_get_old_subscription_key( $subscription ), $subscription->get_user_id() ); + } + + break; + + // New arg spec: $order_items, $renewal_order, $subscription + // Old arg spec: $order_meta_query, $original_order_id, $renewal_order_id, $new_order_role + case 'woocommerce_subscriptions_renewal_order_meta' : + case 'woocommerce_subscriptions_renewal_order_meta_query' : + // Old arg spec: $order_items, $original_order_id, $renewal_order_id, $product_id, $new_order_role + case 'woocommerce_subscriptions_renewal_order_items' : + $renewal_order = $new_callback_args[1]; + $subscription = $new_callback_args[2]; + $original_id = self::get_order_id( $subscription ); + + // Now we need to find the new orders role, if the calling function is wcs_create_resubscribe_order(), the role is parent, otherwise it's child + $backtrace = debug_backtrace(); + $order_role = ( 'wcs_create_resubscribe_order' == $backtrace[1]['function'] ) ? 'parent' : 'child'; + + // Old arg spec: $order_items, $original_order_id, $renewal_order_id, $product_id, $new_order_role + if ( 'woocommerce_subscriptions_renewal_order_items' == $old_hook ) { + $return_value = apply_filters( $old_hook, $return_value, $original_id, $renewal_order->id, self::get_product_id( $subscription ), $order_role ); + } else { + // Old arg spec: $order_meta_query, $original_order_id, $renewal_order_id, $new_order_role + $return_value = apply_filters( $old_hook, $return_value, $original_id, $renewal_order->id, $order_role ); + } + + break; + + // New arg spec: $item_name, $order_item, $subscription + // Old arg spec: $item_name, $order_item, $original_order + case 'woocommerce_subscriptions_renewal_order_item_name' : + $return_value = apply_filters( $old_hook, $return_value, $new_callback_args[1], self::get_order( $new_callback_args[2] ) ); + break; + + // New arg spec: $renewal_order, $subscription + // Old arg spec: $renewal_order, $original_order, $product_id, $new_order_role + case 'woocommerce_subscriptions_renewal_order_created' : + do_action( $old_hook, $return_value, self::get_order( $new_callback_args[1] ), self::get_product_id( $new_callback_args[1] ), 'child' ); + break; + + // New arg spec: $renewal_order, $subscription + // Old arg spec: $renewal_order_id, $original_order, $product_id, $new_order_role + case 'woocommerce_subscriptions_renewal_order_id' : + + $renewal_order = $new_callback_args[0]; + $subscription = $new_callback_args[1]; + + // Now we need to find the new orders role, if the calling function is wcs_create_resubscribe_order(), the role is parent, otherwise it's child + $backtrace = debug_backtrace(); + $order_role = ( 'wcs_create_resubscribe_order' == $backtrace[1]['function'] ) ? 'parent' : 'child'; + + $renewal_order_id = apply_filters( $old_hook, $return_value->id, self::get_order( $subscription ), self::get_product_id( $subscription ), $order_role ); + + // Only change the return value if a new filter was returned by the hook + if ( $renewal_order_id !== $renewal_order->id ) { + $return_value = wc_get_order( $renewal_order_id ); + } + break; + + // New arg spec: $resubscribe_link, $subscription_id + // Old arg spec: $renewal_url, $subscription_key + case 'woocommerce_subscriptions_users_renewal_link' : + $return_value = apply_filters( $old_hook, $return_value, wcs_get_old_subscription_key( wcs_get_subscription( $new_callback_args[1] ) ) ); + break; + + // New arg spec: $can_user_resubscribe, $subscription, $user_id + // Old arg spec: $subscription_can_be_renewed, $subscription, $subscription_key, $user_id + case 'woocommerce_can_subscription_be_renewed' : + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, wcs_get_subscription_in_deprecated_structure( $subscription ), wcs_get_old_subscription_key( $subscription ), $subscription->get_user_id() ); + break; + + // New arg spec: $actions, $subscription + // Old arg spec: $actions, $subscription_array + case 'woocommerce_subscriptions_list_table_actions' : + $return_value = apply_filters( $old_hook, $return_value, wcs_get_subscription_in_deprecated_structure( $new_callback_args[1] ) ); + break; + + // New arg spec: $column_content, $subscription, $actions + // Old arg spec: $column_content, $subscription_array, $actions, $list_table + case 'woocommerce_subscriptions_list_table_column_status_content' : + $return_value = apply_filters( $old_hook, $return_value, wcs_get_subscription_in_deprecated_structure( $new_callback_args[1] ) ); + break; + + // New arg spec: $column_content, $the_subscription, $column + // Old arg spec: $column_content, $subscription_array, $column + case 'woocommerce_subscriptions_list_table_column_content' : + $return_value = apply_filters( $old_hook, $return_value, wcs_get_subscription_in_deprecated_structure( $new_callback_args[1] ), $new_callback_args[2] ); + break; + + // New arg spec: $user_can_suspend, $subscription + // Old arg spec: $user_can_suspend, $subscription_key + case 'woocommerce_subscriptions_can_current_user_suspend' : + $return_value = apply_filters( $old_hook, $return_value, wcs_get_old_subscription_key( $new_callback_args[1] ) ); + break; + + // New arg spec: $actions, $subscription (individual subscription object) + // Old arg spec: $all_actions, $subscriptions (array of subscription arrays) + case 'woocommerce_my_account_my_subscriptions_actions' : + + $subscription = $new_callback_args[1]; + $old_key = wcs_get_old_subscription_key( $subscription ); + + $subscription_in_deprecated_structure = array( + $old_key => wcs_get_subscription_in_deprecated_structure( $subscription ), + ); + + $all_actions = apply_filters( $old_hook, $return_value, $subscription_in_deprecated_structure ); + + // Only change the return value if a new value was returned by the filter + if ( $all_actions !== $return_value ) { + $return_value = $all_actions[ $old_key ]; + } + break; + + // New arg spec: $action_link, $subscription_id, $status + // Old arg spec: $action_link, $subscription_key, $status + case 'woocommerce_subscriptions_users_action_link' : + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, wcs_get_old_subscription_key( wcs_get_subscription( $new_callback_args[1] ) ), $new_callback_args[2] ); + break; + + // New arg spec: failed_payments_exceeded, $subscription + // Old arg spec: $failed_payments_exceeded, $user_id, $subscription_key + case 'woocommerce_subscriptions_max_failed_payments_exceeded' : + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, $subscription->get_user_id(), wcs_get_old_subscription_key( $subscription ) ); + break; + + // New arg spec: $payment_method_to_display, $subscription + // Old arg spec: $payment_method_to_display, $subscription_details, $order + case 'woocommerce_my_subscriptions_recurring_payment_method' : + $subscription = $new_callback_args[1]; + $return_value = apply_filters( $old_hook, $return_value, wcs_get_subscription_in_deprecated_structure( $subscription ), self::get_order( $subscription ) ); + break; + + // New arg spec: $allow_update, $new_payment_method, $subscription + // Old arg spec: $allow_update, $new_payment_method + case 'woocommerce_subscriptions_update_recurring_payment_via_pay_shortcode' : + $return_value = apply_filters( $old_hook, $return_value, $new_callback_args[1] ); + break; + + // New arg spec: $has_subscription, $user_id, $product_id, $status + // Old arg spec: $has_subscription, $user_id, $product_id + case 'woocommerce_user_has_subscription' : + $return_value = apply_filters( $old_hook, $return_value, $new_callback_args[0], $new_callback_args[1] ); + break; + + // New arg spec: $subscriptions (array of objects), $user_id + // Old arg spec: $subscriptions (array of arrays), $user_id + case 'woocommerce_users_subscriptions' : + + // For this hook, the old return value is incompatible with the new return value, so we will trigger another, more urgent notice + trigger_error( 'Callbacks on the "woocommerce_users_subscriptions" filter must be updated immediately. Attach callbacks to the new "wcs_get_users_subscriptions" filter instead. Since version 2.0 of WooCommerce Subscriptions, the "woocommerce_users_subscriptions" filter does not affect the list of a user\'s subscriptions as the subscription data structure has changed.' ); + + // But still trigger the old hook, even if we can't map the old data to the new return value + $subscriptions = $new_callback_args[0]; + $old_subscriptions = array(); + + foreach ( $subscriptions as $subscription ) { + $old_subscriptions[ wcs_get_old_subscription_key( $subscription ) ] = wcs_get_subscription_in_deprecated_structure( $subscription ); + } + + apply_filters( $old_hook, $old_subscriptions, $new_callback_args[1] ); + break; + + // New arg spec: $can_be_updated, $new_status_or_meta, $subscription + // Old arg spec: $can_be_changed, $new_status_or_meta, $args + case 'woocommerce_can_subscription_be_changed_to' : + + $subscription = $new_callback_args[2]; + + // Build the old $arg object + $args = new stdClass(); + $args->subscription_key = wcs_get_old_subscription_key( $subscription ); + $args->subscription = wcs_get_subscription_in_deprecated_structure( $subscription ); + $args->user_id = $subscription->get_user_id(); + $args->order = self::get_order( $subscription ); + $args->payment_gateway = $subscription->payment_method; + $args->order_uses_manual_payments = $subscription->is_manual(); + $return_value = apply_filters( $old_hook, $return_value, $args ); + break; + } + + return $return_value; + } +} +new WCS_Filter_Deprecator(); diff --git a/includes/emails/class-wcs-email-cancelled-subscription.php b/includes/emails/class-wcs-email-cancelled-subscription.php new file mode 100644 index 0000000..6edf483 --- /dev/null +++ b/includes/emails/class-wcs-email-cancelled-subscription.php @@ -0,0 +1,161 @@ +id = 'cancelled_subscription'; + $this->title = __( 'Cancelled Subscription', 'woocommerce-subscriptions' ); + $this->description = __( 'Cancelled Subscription emails are sent when a customer\'s subscription is cancelled (either by a store manager, or the customer).', 'woocommerce-subscriptions' ); + + $this->heading = __( 'Subscription Cancelled', 'woocommerce-subscriptions' ); + // translators: placeholder is {blogname}, a variable that will be substituted when email is sent out + $this->subject = sprintf( _x( '[%s] Subscription Cancelled', 'default email subject for cancelled emails sent to the admin', 'woocommerce-subscriptions' ), '{blogname}' ); + + $this->template_html = 'emails/cancelled-subscription.php'; + $this->template_plain = 'emails/plain/cancelled-subscription.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + add_action( 'cancelled_subscription_notification', array( $this, 'trigger' ) ); + + parent::__construct(); + + $this->recipient = $this->get_option( 'recipient' ); + + if ( ! $this->recipient ) { + $this->recipient = get_option( 'admin_email' ); + } + } + + /** + * trigger function. + * + * @access public + * @return void + */ + function trigger( $subscription ) { + $this->object = $subscription; + + if ( ! is_object( $subscription ) ) { + _deprecated_argument( __METHOD__, '2.0', 'The subscription key is deprecated. Use a subscription post ID' ); + $subscription = wcs_get_subscription_from_key( $subscription ); + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + update_post_meta( $subscription->id, '_cancelled_email_sent', 'true' ); + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'subscription' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'subscription' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * Initialise Settings Form Fields + * + * @access public + * @return void + */ + function init_form_fields() { + $this->form_fields = array( + 'enabled' => array( + 'title' => _x( 'Enable/Disable', 'an email notification', 'woocommerce-subscriptions' ), + 'type' => 'checkbox', + 'label' => __( 'Enable this email notification', 'woocommerce-subscriptions' ), + 'default' => 'no', + ), + 'recipient' => array( + 'title' => _x( 'Recipient(s)', 'of an email', 'woocommerce-subscriptions' ), + 'type' => 'text', + // translators: placeholder is admin email + 'description' => sprintf( __( 'Enter recipients (comma separated) for this email. Defaults to %s.', 'woocommerce-subscriptions' ), esc_attr( get_option( 'admin_email' ) ) ), + 'placeholder' => '', + 'default' => '', + ), + 'subject' => array( + 'title' => _x( 'Subject', 'of an email', 'woocommerce-subscriptions' ), + 'type' => 'text', + 'description' => sprintf( __( 'This controls the email subject line. Leave blank to use the default subject: %s.', 'woocommerce-subscriptions' ), $this->subject ), + 'placeholder' => '', + 'default' => '', + ), + 'heading' => array( + 'title' => _x( 'Email Heading', 'Name the setting that controls the main heading contained within the email notification', 'woocommerce-subscriptions' ), + 'type' => 'text', + 'description' => sprintf( __( 'This controls the main heading contained within the email notification. Leave blank to use the default heading: %s.', 'woocommerce-subscriptions' ), $this->heading ), + 'placeholder' => '', + 'default' => '', + ), + 'email_type' => array( + 'title' => _x( 'Email type', 'text, html or multipart', 'woocommerce-subscriptions' ), + 'type' => 'select', + 'description' => __( 'Choose which format of email to send.', 'woocommerce-subscriptions' ), + 'default' => 'html', + 'class' => 'email_type', + 'options' => array( + 'plain' => _x( 'Plain text', 'email type', 'woocommerce-subscriptions' ), + 'html' => _x( 'HTML', 'email type', 'woocommerce-subscriptions' ), + 'multipart' => _x( 'Multipart', 'email type', 'woocommerce-subscriptions' ), + ), + ), + ); + } +} diff --git a/includes/emails/class-wcs-email-customer-completed-renewal-order.php b/includes/emails/class-wcs-email-customer-completed-renewal-order.php new file mode 100644 index 0000000..6aa9e75 --- /dev/null +++ b/includes/emails/class-wcs-email-customer-completed-renewal-order.php @@ -0,0 +1,147 @@ +id = 'customer_completed_renewal_order'; + $this->title = __( 'Completed Renewal Order', 'woocommerce-subscriptions' ); + $this->description = __( 'Renewal order complete emails are sent to the customer when a subscription renewal order is marked complete and usually indicates that the item for that renewal period has been shipped.', 'woocommerce-subscriptions' ); + $this->customer_email = true; + + $this->heading = _x( 'Your renewal order is complete', 'Default email heading for email to customer on completed renewal order', 'woocommerce-subscriptions' ); + // translators: $1: {blogname}, $2: {order_date}, variables that will be substituted when email is sent out + $this->subject = sprintf( _x( 'Your %1$s renewal order from %2$s is complete', 'Default email subject for email to customer on completed renewal order', 'woocommerce-subscriptions' ), '{blogname}', '{order_date}' ); + + $this->template_html = 'emails/customer-completed-renewal-order.php'; + $this->template_plain = 'emails/plain/customer-completed-renewal-order.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + // Other settings + $this->heading_downloadable = $this->get_option( 'heading_downloadable', _x( 'Your subscription renewal order is complete - download your files', 'Default email heading for email with downloadable files in it', 'woocommerce-subscriptions' ) ); + // translators: $1: {blogname}, $2: {order_date}, variables will be substituted when email is sent out + $this->subject_downloadable = $this->get_option( 'subject_downloadable', sprintf( _x( 'Your %1$s subscription renewal order from %2$s is complete - download your files', 'Default email subject for email with downloadable files in it', 'woocommerce-subscriptions' ), '{blogname}', '{order_date}' ) ); + + // Triggers for this email + add_action( 'woocommerce_order_status_completed_renewal_notification', array( $this, 'trigger' ) ); + + // We want most of the parent's methods, with none of its properties, so call its parent's constructor + WC_Email::__construct(); + } + + /** + * trigger function. + * + * We need to override WC_Email_Customer_Completed_Order's trigger method because it expects to be run only once + * per request (but multiple subscription renewal orders can be generated per request). + * + * @access public + * @return void + */ + function trigger( $order_id ) { + + if ( $order_id ) { + $this->object = new WC_Order( $order_id ); + $this->recipient = $this->object->billing_email; + + $order_date_index = array_search( '{order_date}', $this->find ); + if ( false === $order_date_index ) { + $this->find[] = '{order_date}'; + $this->replace[] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } else { + $this->replace[ $order_date_index ] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } + + $order_number_index = array_search( '{order_number}', $this->find ); + if ( false === $order_number_index ) { + $this->find[] = '{order_number}'; + $this->replace[] = $this->object->get_order_number(); + } else { + $this->replace[ $order_number_index ] = $this->object->get_order_number(); + } + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_subject function. + * + * @access public + * @return string + */ + function get_subject() { + return apply_filters( 'woocommerce_subscriptions_email_subject_customer_completed_renewal_order', parent::get_subject(), $this->object ); + } + + /** + * get_heading function. + * + * @access public + * @return string + */ + function get_heading() { + return apply_filters( 'woocommerce_email_heading_customer_renewal_order', parent::get_heading(), $this->object ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } +} diff --git a/includes/emails/class-wcs-email-customer-completed-switch-order.php b/includes/emails/class-wcs-email-customer-completed-switch-order.php new file mode 100644 index 0000000..ab083b8 --- /dev/null +++ b/includes/emails/class-wcs-email-customer-completed-switch-order.php @@ -0,0 +1,150 @@ +id = 'customer_completed_switch_order'; + $this->title = __( 'Subscription Switch Complete', 'woocommerce-subscriptions' ); + $this->description = __( 'Subscription switch complete emails are sent to the customer when a subscription is switched successfully.', 'woocommerce-subscriptions' ); + $this->customer_email = true; + + $this->heading = __( 'Your subscription change is complete', 'woocommerce-subscriptions' ); + $this->subject = __( 'Your {blogname} subscription change from {order_date} is complete', 'woocommerce-subscriptions' ); + + $this->template_html = 'emails/customer-completed-switch-order.php'; + $this->template_plain = 'emails/plain/customer-completed-switch-order.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + // Other settings + $this->heading_downloadable = $this->get_option( 'heading_downloadable', __( 'Your subscription change is complete - download your files', 'woocommerce-subscriptions' ) ); + $this->subject_downloadable = $this->get_option( 'subject_downloadable', __( 'Your {blogname} subscription change from {order_date} is complete - download your files', 'woocommerce-subscriptions' ) ); + + // Triggers for this email + add_action( 'woocommerce_order_status_completed_switch_notification', array( $this, 'trigger' ) ); + + // We want most of the parent's methods, with none of its properties, so call its parent's constructor + WC_Email::__construct(); + } + + /** + * trigger function. + * + * We need to override WC_Email_Customer_Completed_Order's trigger method because it expects to be run only once + * per request (but multiple subscription switch orders can be generated per request). + * + * @access public + * @return void + */ + function trigger( $order_id ) { + + if ( $order_id ) { + $this->object = new WC_Order( $order_id ); + $this->recipient = $this->object->billing_email; + + $order_date_index = array_search( '{order_date}', $this->find ); + if ( false === $order_date_index ) { + $this->find[] = '{order_date}'; + $this->replace[] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } else { + $this->replace[ $order_date_index ] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } + + $order_number_index = array_search( '{order_number}', $this->find ); + if ( false === $order_number_index ) { + $this->find[] = '{order_number}'; + $this->replace[] = $this->object->get_order_number(); + } else { + $this->replace[ $order_number_index ] = $this->object->get_order_number(); + } + + $this->subscriptions = wcs_get_subscriptions_for_switch_order( $this->object ); + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_subject function. + * + * @access public + * @return string + */ + function get_subject() { + return apply_filters( 'woocommerce_subscriptions_email_subject_customer_completed_switch_order', parent::get_subject(), $this->object ); + } + + /** + * get_heading function. + * + * @access public + * @return string + */ + function get_heading() { + return apply_filters( 'woocommerce_email_heading_customer_switch_order', parent::get_heading(), $this->object ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'order' => $this->object, + 'subscriptions' => $this->subscriptions, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'order' => $this->object, + 'subscriptions' => $this->subscriptions, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } +} diff --git a/includes/emails/class-wcs-email-customer-processing-renewal-order.php b/includes/emails/class-wcs-email-customer-processing-renewal-order.php new file mode 100644 index 0000000..e4829b3 --- /dev/null +++ b/includes/emails/class-wcs-email-customer-processing-renewal-order.php @@ -0,0 +1,141 @@ +id = 'customer_processing_renewal_order'; + $this->title = __( 'Processing Renewal order', 'woocommerce-subscriptions' ); + $this->description = __( 'This is an order notification sent to the customer after payment for a subscription renewal order is completed. It contains the renewal order details.', 'woocommerce-subscriptions' ); + $this->customer_email = true; + + $this->heading = __( 'Thank you for your order', 'woocommerce-subscriptions' ); + $this->subject = __( 'Your {blogname} renewal order receipt from {order_date}', 'woocommerce-subscriptions' ); + + $this->template_html = 'emails/customer-processing-renewal-order.php'; + $this->template_plain = 'emails/plain/customer-processing-renewal-order.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + // Triggers for this email + add_action( 'woocommerce_order_status_pending_to_processing_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_pending_to_on-hold_renewal_notification', array( $this, 'trigger' ) ); + + // We want all the parent's methods, with none of its properties, so call its parent's constructor + WC_Email::__construct(); + } + + /** + * trigger function. + * + * We need to override WC_Email_Customer_Processing_Order's trigger method because it expects to be run only once + * per request (but multiple subscription renewal orders can be generated per request). + * + * @access public + * @return void + */ + function trigger( $order_id ) { + + if ( $order_id ) { + $this->object = new WC_Order( $order_id ); + $this->recipient = $this->object->billing_email; + + $order_date_index = array_search( '{order_date}', $this->find ); + if ( false === $order_date_index ) { + $this->find[] = '{order_date}'; + $this->replace[] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } else { + $this->replace[ $order_date_index ] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } + + $order_number_index = array_search( '{order_number}', $this->find ); + if ( false === $order_number_index ) { + $this->find[] = '{order_number}'; + $this->replace[] = $this->object->get_order_number(); + } else { + $this->replace[ $order_number_index ] = $this->object->get_order_number(); + } + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_subject function. + * + * @access public + * @return string + */ + function get_subject() { + return apply_filters( 'woocommerce_subscriptions_email_subject_customer_processing_renewal_order', parent::get_subject(), $this->object ); + } + + /** + * get_heading function. + * + * @access public + * @return string + */ + function get_heading() { + return apply_filters( 'woocommerce_email_heading_customer_renewal_order', parent::get_heading(), $this->object ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } +} diff --git a/includes/emails/class-wcs-email-customer-renewal-invoice.php b/includes/emails/class-wcs-email-customer-renewal-invoice.php new file mode 100644 index 0000000..0857a19 --- /dev/null +++ b/includes/emails/class-wcs-email-customer-renewal-invoice.php @@ -0,0 +1,175 @@ +id = 'customer_renewal_invoice'; + $this->title = __( 'Customer Renewal Invoice', 'woocommerce-subscriptions' ); + $this->description = __( 'Sent to a customer when the subscription is due for renewal and the renewal requires a manual payment, either because it uses manual renewals or the automatic recurring payment failed. The email contains renewal order information and payment links.', 'woocommerce-subscriptions' ); + $this->customer_email = true; + + $this->template_html = 'emails/customer-renewal-invoice.php'; + $this->template_plain = 'emails/plain/customer-renewal-invoice.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + $this->subject = __( 'Invoice for renewal order {order_number} from {order_date}', 'woocommerce-subscriptions' ); + $this->heading = __( 'Invoice for renewal order {order_number}', 'woocommerce-subscriptions' ); + + $this->subject_paid = __( 'Your {blogname} renewal order from {order_date}', 'woocommerce-subscriptions' ); + $this->heading_paid = __( 'Renewal order {order_number} details', 'woocommerce-subscriptions' ); + + // Triggers for this email + add_action( 'woocommerce_generated_manual_renewal_order_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_renewal_notification', array( $this, 'trigger' ) ); + + // We want all the parent's methods, with none of its properties, so call its parent's constructor, rather than my parent constructor + WC_Email::__construct(); + } + + /** + * trigger function. + * + * We need to override WC_Email_Customer_Invoice's trigger method because it expects to be run only once + * per request (but multiple subscription renewal orders can be generated per request). + * + * @access public + * @return void + */ + function trigger( $order ) { + + if ( ! is_object( $order ) ) { + $order = new WC_Order( absint( $order ) ); + } + + if ( $order ) { + $this->object = $order; + $this->recipient = $this->object->billing_email; + + $order_date_index = array_search( '{order_date}', $this->find ); + if ( false === $order_date_index ) { + $this->find[] = '{order_date}'; + $this->replace[] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } else { + $this->replace[ $order_date_index ] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } + + $order_number_index = array_search( '{order_number}', $this->find ); + if ( false === $order_number_index ) { + $this->find[] = '{order_number}'; + $this->replace[] = $this->object->get_order_number(); + } else { + $this->replace[ $order_number_index ] = $this->object->get_order_number(); + } + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_subject function. + * + * @access public + * @return string + */ + function get_subject() { + return apply_filters( 'woocommerce_subscriptions_email_subject_new_renewal_order', parent::get_subject(), $this->object ); + } + + /** + * get_heading function. + * + * @access public + * @return string + */ + function get_heading() { + return apply_filters( 'woocommerce_email_heading_customer_renewal_order', parent::get_heading(), $this->object ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * Initialise Settings Form Fields, but add an enable/disable field + * to this email as WC doesn't include that for customer Invoices. + * + * @access public + * @return void + */ + function init_form_fields() { + + parent::init_form_fields(); + + $this->form_fields = array_merge( + array( + 'enabled' => array( + 'title' => _x( 'Enable/Disable', 'an email notification', 'woocommerce-subscriptions' ), + 'type' => 'checkbox', + 'label' => __( 'Enable this email notification', 'woocommerce-subscriptions' ), + 'default' => 'yes', + ), + ), + $this->form_fields + ); + } +} diff --git a/includes/emails/class-wcs-email-new-renewal-order.php b/includes/emails/class-wcs-email-new-renewal-order.php new file mode 100644 index 0000000..100ac68 --- /dev/null +++ b/includes/emails/class-wcs-email-new-renewal-order.php @@ -0,0 +1,128 @@ +id = 'new_renewal_order'; + $this->title = __( 'New Renewal Order', 'woocommerce-subscriptions' ); + $this->description = __( 'New renewal order emails are sent when a subscription renewal payment is processed.', 'woocommerce-subscriptions' ); + + $this->heading = __( 'New subscription renewal order', 'woocommerce-subscriptions' ); + $this->subject = __( '[{blogname}] New subscription renewal order ({order_number}) - {order_date}', 'woocommerce-subscriptions' ); + + $this->template_html = 'emails/admin-new-renewal-order.php'; + $this->template_plain = 'emails/plain/admin-new-renewal-order.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + // Triggers for this email + add_action( 'woocommerce_order_status_pending_to_processing_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_pending_to_completed_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_pending_to_on-hold_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_to_processing_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_to_completed_renewal_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_to_on-hold_renewal_notification', array( $this, 'trigger' ) ); + + // We want all the parent's methods, with none of its properties, so call its parent's constructor, rather than my parent constructor + WC_Email::__construct(); + + // Other settings + $this->recipient = $this->get_option( 'recipient' ); + + if ( ! $this->recipient ) { + $this->recipient = get_option( 'admin_email' ); + } + } + + /** + * trigger function. + * + * We need to override WC_Email_New_Order's trigger method because it expects to be run only once + * per request (but multiple subscription renewal orders can be generated per request). + * + * @access public + * @return void + */ + function trigger( $order_id ) { + + if ( $order_id ) { + $this->object = new WC_Order( $order_id ); + + $order_date_index = array_search( '{order_date}', $this->find ); + if ( false === $order_date_index ) { + $this->find[] = '{order_date}'; + $this->replace[] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } else { + $this->replace[ $order_date_index ] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } + + $order_number_index = array_search( '{order_number}', $this->find ); + if ( false === $order_number_index ) { + $this->find[] = '{order_number}'; + $this->replace[] = $this->object->get_order_number(); + } else { + $this->replace[ $order_number_index ] = $this->object->get_order_number(); + } + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } +} diff --git a/includes/emails/class-wcs-email-new-switch-order.php b/includes/emails/class-wcs-email-new-switch-order.php new file mode 100644 index 0000000..1156d98 --- /dev/null +++ b/includes/emails/class-wcs-email-new-switch-order.php @@ -0,0 +1,132 @@ +id = 'new_switch_order'; + $this->title = __( 'Subscription Switched', 'woocommerce-subscriptions' ); + $this->description = __( 'Subscription switched emails are sent when a customer switches a subscription.', 'woocommerce-subscriptions' ); + + $this->heading = __( 'Subscription Switched', 'woocommerce-subscriptions' ); + $this->subject = __( '[{blogname}] Subscription Switched ({order_number}) - {order_date}', 'woocommerce-subscriptions' ); + + $this->template_html = 'emails/admin-new-switch-order.php'; + $this->template_plain = 'emails/plain/admin-new-switch-order.php'; + $this->template_base = plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/'; + + // Triggers for this email + add_action( 'woocommerce_order_status_pending_to_processing_switch_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_pending_to_completed_switch_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_pending_to_on-hold_switch_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_to_processing_switch_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_to_completed_switch_notification', array( $this, 'trigger' ) ); + add_action( 'woocommerce_order_status_failed_to_on-hold_switch_notification', array( $this, 'trigger' ) ); + + // We want all the parent's methods, with none of its properties, so call its parent's constructor, rather than my parent constructor + WC_Email::__construct(); + + // Other settings + $this->recipient = $this->get_option( 'recipient' ); + + if ( ! $this->recipient ) { + $this->recipient = get_option( 'admin_email' ); + } + } + + /** + * trigger function. + * + * We need to override WC_Email_New_Order's trigger method because it expects to be run only once + * per request. + * + * @access public + * @return void + */ + function trigger( $order_id ) { + + if ( $order_id ) { + $this->object = new WC_Order( $order_id ); + + $order_date_index = array_search( '{order_date}', $this->find ); + if ( false === $order_date_index ) { + $this->find[] = '{order_date}'; + $this->replace[] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } else { + $this->replace[ $order_date_index ] = date_i18n( wc_date_format(), strtotime( $this->object->order_date ) ); + } + + $order_number_index = array_search( '{order_number}', $this->find ); + if ( false === $order_number_index ) { + $this->find[] = '{order_number}'; + $this->replace[] = $this->object->get_order_number(); + } else { + $this->replace[ $order_number_index ] = $this->object->get_order_number(); + } + + $this->subscriptions = wcs_get_subscriptions_for_switch_order( $this->object ); + } + + if ( ! $this->is_enabled() || ! $this->get_recipient() ) { + return; + } + + $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); + } + + /** + * get_content_html function. + * + * @access public + * @return string + */ + function get_content_html() { + ob_start(); + wc_get_template( + $this->template_html, + array( + 'order' => $this->object, + 'subscriptions' => $this->subscriptions, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } + + /** + * get_content_plain function. + * + * @access public + * @return string + */ + function get_content_plain() { + ob_start(); + wc_get_template( + $this->template_plain, + array( + 'order' => $this->object, + 'subscriptions' => $this->subscriptions, + 'email_heading' => $this->get_heading(), + ), + '', + $this->template_base + ); + return ob_get_clean(); + } +} diff --git a/includes/gateways/class-wc-subscriptions-payment-gateways.php b/includes/gateways/class-wc-subscriptions-payment-gateways.php new file mode 100644 index 0000000..151fd36 --- /dev/null +++ b/includes/gateways/class-wc-subscriptions-payment-gateways.php @@ -0,0 +1,254 @@ +payment_gateways ) { + foreach ( WC()->payment_gateways->payment_gateways() as $gateway ) { + if ( $gateway_id == $gateway->id ) { + $found_gateway = $gateway; + } + } + } + + return $found_gateway; + } + + /** + * Only display the gateways which support subscriptions if manual payments are not allowed. + * + * @since 1.0 + */ + public static function get_available_payment_gateways( $available_gateways ) { + + if ( WC_Subscriptions_Cart::cart_contains_subscription() || ( isset( $_GET['order_id'] ) && wcs_order_contains_subscription( $_GET['order_id'] ) ) ) { + + $accept_manual_renewals = ( 'no' !== get_option( WC_Subscriptions_Admin::$option_prefix . '_accept_manual_renewals', 'no' ) ) ? true : false; + $subscriptions_in_cart = count( WC()->cart->recurring_carts ); + + foreach ( $available_gateways as $gateway_id => $gateway ) { + + $supports_subscriptions = $gateway->supports( 'subscriptions' ); + + // Remove the payment gateway if there are multiple subscriptions in the cart and this gateway either doesn't support multiple subscriptions or isn't manual (all manual gateways support multiple subscriptions) + if ( $subscriptions_in_cart > 1 && $gateway->supports( 'multiple_subscriptions' ) !== true && ( $supports_subscriptions || ! $accept_manual_renewals ) ) { + unset( $available_gateways[ $gateway_id ] ); + + // If there is just the one subscription the cart, remove the payment gateway if manual renewals are disabled and this gateway doesn't support automatic payments + } elseif ( ! $supports_subscriptions && ! $accept_manual_renewals ) { + unset( $available_gateways[ $gateway_id ] ); + } + } + } + + return $available_gateways; + } + + /** + * Helper function to check if at least one payment gateway on the site supports a certain subscription feature. + * + * @since 2.0 + */ + public static function one_gateway_supports( $supports_flag ) { + + // Only check if we haven't already run the check + if ( ! isset( self::$one_gateway_supports[ $supports_flag ] ) ) { + + self::$one_gateway_supports[ $supports_flag ] = false; + + foreach ( WC()->payment_gateways->get_available_payment_gateways() as $gateway ) { + if ( $gateway->supports( $supports_flag ) ) { + self::$one_gateway_supports[ $supports_flag ] = true; + break; + } + } + } + + return self::$one_gateway_supports[ $supports_flag ]; + } + + /** + * Improve message displayed on checkout when a subscription is in the cart but not gateways support subscriptions. + * + * @since 1.5.2 + */ + public static function no_available_payment_methods_message( $no_gateways_message ) { + if ( WC_Subscriptions_Cart::cart_contains_subscription() && 'no' == get_option( WC_Subscriptions_Admin::$option_prefix . '_accept_manual_renewals', 'no' ) ) { + $no_gateways_message = __( 'Sorry, it seems there are no available payment methods which support subscriptions. Please contact us if you require assistance or wish to make alternate arrangements.', 'woocommerce-subscriptions' ); + } + + return $no_gateways_message; + } + + /** + * Fire a gateway specific whenever a subscription's status is changed. + * + * @since 2.0 + */ + public static function trigger_gateway_status_updated_hook( $subscription, $new_status ) { + + if ( $subscription->is_manual() ) { + return; + } + + switch ( $new_status ) { + case 'active' : + $hook_prefix = 'woocommerce_subscription_activated_'; + break; + case 'on-hold' : + $hook_prefix = 'woocommerce_subscription_on-hold_'; + break; + case 'pending-cancel' : + $hook_prefix = 'woocommerce_subscription_pending-cancel_'; + break; + case 'cancelled' : + $hook_prefix = 'woocommerce_subscription_cancelled_'; + break; + case 'expired' : + $hook_prefix = 'woocommerce_subscription_expired_'; + break; + default : + $hook_prefix = apply_filters( 'woocommerce_subscriptions_gateway_status_updated_hook_prefix', 'woocommerce_subscription_status_updated_', $subscription, $new_status ); + break; + } + + do_action( $hook_prefix . $subscription->payment_method, $subscription ); + } + + /** + * Fire a gateway specific hook for when a subscription payment is due. + * + * @since 1.0 + */ + public static function gateway_scheduled_subscription_payment( $subscription_id, $deprecated = null ) { + + // Passing the old $user_id/$subscription_key parameters + if ( null != $deprecated ) { + _deprecated_argument( __METHOD__, '2.0', 'Second parameter is deprecated' ); + $subscription = wcs_get_subscription_from_key( $deprecated ); + } else { + $subscription = wcs_get_subscription( $subscription_id ); + } + + if ( false === $subscription ) { + throw new InvalidArgumentException( sprintf( __( 'Subscription doesn\'t exist in scheduled action: %d', 'woocommerce-subscriptions' ), $subscription_id ) ); + } + + if ( ! $subscription->is_manual() && $subscription->get_total() > 0 && ! empty( $subscription->payment_method ) ) { + do_action( 'woocommerce_scheduled_subscription_payment_' . $subscription->payment_method, $subscription->get_total(), $subscription->get_last_order( 'all' ) ); + } + } + + /** + * Fire a gateway specific hook for when a subscription is activated. + * + * @since 1.0 + */ + public static function trigger_gateway_activated_subscription_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::trigger_gateway_status_updated_hook()' ); + self::trigger_gateway_status_updated_hook( wcs_get_subscription_from_key( $subscription_key ), 'active' ); + } + + /** + * Fire a gateway specific hook for when a subscription is activated. + * + * @since 1.0 + */ + public static function trigger_gateway_reactivated_subscription_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::trigger_gateway_status_updated_hook()' ); + self::trigger_gateway_status_updated_hook( wcs_get_subscription_from_key( $subscription_key ), 'active' ); + } + + /** + * Fire a gateway specific hook for when a subscription is on-hold. + * + * @since 1.2 + */ + public static function trigger_gateway_subscription_put_on_hold_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::trigger_gateway_status_updated_hook()' ); + self::trigger_gateway_status_updated_hook( wcs_get_subscription_from_key( $subscription_key ), 'on-hold' ); + } + + /** + * Fire a gateway specific when a subscription is cancelled. + * + * @since 1.0 + */ + public static function trigger_gateway_cancelled_subscription_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::trigger_gateway_status_updated_hook()' ); + self::trigger_gateway_status_updated_hook( wcs_get_subscription_from_key( $subscription_key ), 'cancelled' ); + } + + /** + * Fire a gateway specific hook when a subscription expires. + * + * @since 1.0 + */ + public static function trigger_gateway_subscription_expired_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '2.0', __CLASS__ . '::trigger_gateway_status_updated_hook()' ); + self::trigger_gateway_status_updated_hook( wcs_get_subscription_from_key( $subscription_key ), 'expired' ); + } + + /** + * Fired a gateway specific when a subscription was suspended. Suspended status was changed in 1.2 to match + * WooCommerce with the "on-hold" status. + * + * @deprecated 1.2 + * @since 1.0 + */ + public static function trigger_gateway_suspended_subscription_hook( $user_id, $subscription_key ) { + _deprecated_function( __METHOD__, '1.2', __CLASS__ . '::trigger_gateway_subscription_put_on_hold_hook( $subscription_key, $user_id )' ); + self::trigger_gateway_subscription_put_on_hold_hook( $subscription_key, $user_id ); + } +} + +WC_Subscriptions_Payment_Gateways::init(); diff --git a/includes/gateways/paypal/class-wcs-paypal.php b/includes/gateways/paypal/class-wcs-paypal.php new file mode 100644 index 0000000..3bd34e4 --- /dev/null +++ b/includes/gateways/paypal/class-wcs-paypal.php @@ -0,0 +1,558 @@ +are_reference_transactions_enabled() ) { + $accounts_with_reference_transactions_enabled[] = $api_username; + update_option( 'wcs_paypal_rt_enabled_accounts', wcs_json_encode( $accounts_with_reference_transactions_enabled ) ); + $reference_transactions_enabled = true; + } else { + set_transient( $transient_key, $api_username, DAY_IN_SECONDS ); + } + } + } + + return apply_filters( 'wooocommerce_subscriptions_paypal_reference_transactions_enabled', $reference_transactions_enabled ); + } + + /** + * Handle WC API requests where we need to run a reference transaction API operation + * + * @since 2.0 + */ + public static function handle_wc_api() { + + if ( ! isset( $_GET['action'] ) ) { + return; + } + + switch ( $_GET['action'] ) { + + // called when the customer is returned from PayPal after authorizing their payment, used for retrieving the customer's checkout details + case 'create_billing_agreement' : + + // bail if no token + if ( ! isset( $_GET['token'] ) ) { + return; + } + + // get token to retrieve checkout details with + $token = esc_attr( $_GET['token'] ); + + try { + + $express_checkout_details_response = self::get_api()->get_express_checkout_details( $token ); + + // Make sure the billing agreement was accepted + if ( 1 == $express_checkout_details_response->get_billing_agreement_status() ) { + + $order = $express_checkout_details_response->get_order(); + + if ( is_null( $order ) ) { + throw new Exception( __( 'Unable to find order for PayPal billing agreement.', 'woocommerce-subscriptions' ) ); + } + + // we need to process an initial payment + if ( $order->get_total() > 0 && ! wcs_is_subscription( $order ) ) { + $billing_agreement_response = self::get_api()->do_express_checkout( $token, $order, array( + 'payment_action' => 'Sale', + 'payer_id' => $express_checkout_details_response->get_payer_id(), + ) ); + } else { + $billing_agreement_response = self::get_api()->create_billing_agreement( $token ); + } + + if ( $billing_agreement_response->has_api_error() ) { + throw new Exception( $billing_agreement_response->get_api_error_message(), $billing_agreement_response->get_api_error_code() ); + } + + // We're changing the payment method for a subscription, make sure we update it before updating the billing agreement ID so that an old PayPal subscription can be cancelled if the existing payment method is also PayPal + if ( wcs_is_subscription( $order ) ) { + WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $order, 'paypal' ); + $redirect_url = add_query_arg( 'utm_nooverride', '1', $order->get_view_order_url() ); + } + + // Make sure PayPal is set as the payment method on the order and subscription + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + $payment_method = isset( $available_gateways[ self::instance()->get_id() ] ) ? $available_gateways[ self::instance()->get_id() ] : false; + $order->set_payment_method( $payment_method ); + + // Store the billing agreement ID on the order and subscriptions + wcs_set_paypal_id( $order, $billing_agreement_response->get_billing_agreement_id() ); + + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'any' ) ) as $subscription ) { + $subscription->set_payment_method( $payment_method ); + wcs_set_paypal_id( $subscription, $billing_agreement_response->get_billing_agreement_id() ); + } + + if ( ! wcs_is_subscription( $order ) ) { + + if ( 0 == $order->get_total() ) { + $order->payment_complete(); + } else { + self::process_subscription_payment_response( $order, $billing_agreement_response ); + } + + $redirect_url = add_query_arg( 'utm_nooverride', '1', $order->get_checkout_order_received_url() ); + } + + // redirect customer to order received page + wp_safe_redirect( esc_url_raw( $redirect_url ) ); + + } else { + + wp_safe_redirect( WC()->cart->get_cart_url() ); + + } + } catch ( Exception $e ) { + + wc_add_notice( __( 'An error occurred, please try again or try an alternate form of payment.', 'woocommerce-subscriptions' ), 'error' ); + + wp_redirect( WC()->cart->get_cart_url() ); + } + + exit; + + case 'reference_transaction_account_check' : + exit; + } + } + + /** + * Override the default PayPal standard args in WooCommerce for subscription purchases when + * automatic payments are enabled and when the recurring order totals is over $0.00 (because + * PayPal doesn't support subscriptions with a $0 recurring total, we need to circumvent it and + * manage it entirely ourselves.) + * + * @since 2.0 + */ + public static function get_paypal_args( $paypal_args, $order ) { + + if ( wcs_order_contains_subscription( $order, array( 'parent', 'renewal', 'resubscribe', 'switch' ) ) || wcs_is_subscription( $order ) ) { + if ( self::are_reference_transactions_enabled() ) { + $paypal_args = self::get_api()->get_paypal_args( $paypal_args, $order ); + } else { + $paypal_args = WCS_PayPal_Standard_Request::get_paypal_args( $paypal_args, $order ); + } + } + + return $paypal_args; + } + + /** + * When a PayPal IPN messaged is received for a subscription transaction, + * check the transaction details and + * + * @link https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNandPDTVariables/ + * + * @since 2.0 + */ + public static function process_ipn_request( $transaction_details ) { + + require_once( 'includes/class-wcs-paypal-standard-ipn-handler.php' ); + require_once( 'includes/class-wcs-paypal-reference-transaction-ipn-handler.php' ); + + if ( ! isset( $transaction_details['txn_type'] ) || ! in_array( $transaction_details['txn_type'], array_merge( self::get_ipn_handler( 'standard' )->get_transaction_types(), self::get_ipn_handler( 'reference' )->get_transaction_types() ) ) ) { + return; + } + + WC_Gateway_Paypal::log( 'Subscription Transaction Type: ' . $transaction_details['txn_type'] ); + WC_Gateway_Paypal::log( 'Subscription Transaction Details: ' . print_r( $transaction_details, true ) ); + + if ( in_array( $transaction_details['txn_type'], self::get_ipn_handler( 'standard' )->get_transaction_types() ) ) { + self::get_ipn_handler( 'standard' )->valid_response( $transaction_details ); + } elseif ( in_array( $transaction_details['txn_type'], self::get_ipn_handler( 'reference' )->get_transaction_types() ) ) { + self::get_ipn_handler( 'reference' )->valid_response( $transaction_details ); + } + } + + /** + * Check whether a given subscription is using reference transactions and if so process the payment. + * + * @since 2.0 + */ + public static function process_subscription_payment( $amount, $order ) { + + // If the subscription is using reference transactions, we can process the payment ourselves + $paypal_profile_id = wcs_get_paypal_id( $order->id ); + + if ( wcs_is_paypal_profile_a( $paypal_profile_id, 'billing_agreement' ) ) { + + if ( 0 == $amount ) { + $order->payment_complete(); + return; + } + + $response = self::get_api()->do_reference_transaction( $paypal_profile_id, $order, array( + 'amount' => $amount, + 'invoice_number' => self::get_option( 'invoice_prefix' ) . wcs_str_to_ascii( ltrim( $order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) ), + ) ); + + self::process_subscription_payment_response( $order, $response ); + } + } + + /** + * Process a payment based on a response + * + * @since 2.0.9 + */ + public static function process_subscription_payment_response( $order, $response ) { + + if ( $response->has_api_error() ) { + + $error_message = $response->get_api_error_message(); + + // Some PayPal error messages end with a fullstop, others do not, we prefer our punctuation consistent, so add one if we don't already have one. + if ( '.' !== substr( $error_message, -1 ) ) { + $error_message .= '.'; + } + + // translators: placeholders are PayPal API error code and PayPal API error message + $order->update_status( 'failed', sprintf( __( 'PayPal API error: (%d) %s', 'woocommerce-subscriptions' ), $response->get_api_error_code(), $error_message ) ); + + } elseif ( $response->transaction_held() ) { + + // translators: placeholder is PayPal transaction status message + $order_note = sprintf( __( 'PayPal Transaction Held: %s', 'woocommerce-subscriptions' ), $response->get_status_message() ); + $order_status = apply_filters( 'wcs_paypal_held_payment_order_status', 'on-hold', $order, $response ); + + // mark order as held + if ( ! $order->has_status( $order_status ) ) { + $order->update_status( $order_status, $order_note ); + } else { + $order->add_order_note( $order_note ); + } + } elseif ( ! $response->transaction_approved() ) { + + // translators: placeholder is PayPal transaction status message + $order->update_status( 'failed', sprintf( __( 'PayPal payment declined: %s', 'woocommerce-subscriptions' ), $response->get_status_message() ) ); + + } elseif ( $response->transaction_approved() ) { + + $order->add_order_note( sprintf( __( 'PayPal payment approved (ID: %s)', 'woocommerce-subscriptions' ), $response->get_transaction_id() ) ); + + $order->payment_complete( $response->get_transaction_id() ); + } + } + + /** + * Don't transfer PayPal meta to resubscribe orders. + * + * @param object $resubscribe_order The order created for resubscribing the subscription + * @param object $subscription The subscription to which the resubscribe order relates + * @return object + * @since 2.0 + */ + public static function remove_resubscribe_order_meta( $resubscribe_order, $subscription ) { + + $post_meta_keys = array( + 'Transaction ID', + 'Payer first name', + 'Payer last name', + 'Payer PayPal address', + 'Payer PayPal first name', + 'Payer PayPal last name', + 'PayPal Subscriber ID', + 'Payment type', + ); + + foreach ( $post_meta_keys as $post_meta_key ) { + delete_post_meta( $resubscribe_order->id, $post_meta_key ); + } + + return $resubscribe_order; + } + + /** + * Maybe adds a warning message to subscription script parameters which is used in a Javascript dialog if the + * payment method of the subscription is set to be changed. The warning message is only added if the subscriptions + * payment gateway is PayPal Standard. + * + * @param array $script_parameters The script parameters used in subscription meta boxes. + * @return array $script_parameters + * @since 2.0 + */ + public static function maybe_add_change_payment_method_warning( $script_parameters ) { + global $post; + $subscription = wcs_get_subscription( $post ); + + if ( 'paypal' === $subscription->payment_method ) { + + $paypal_profile_id = wcs_get_paypal_id( $subscription->id ); + $is_paypal_standard = ! wcs_is_paypal_profile_a( $paypal_profile_id, 'billing_agreement' ); + + if ( $is_paypal_standard ) { + $script_parameters['change_payment_method_warning'] = __( "Are you sure you want to change the payment method from PayPal standard?\n\nThis will suspend the subscription at PayPal.", 'woocommerce-subscriptions' ); + } + } + + return $script_parameters; + } + + /** Getters ******************************************************/ + + /** + * Get the API object + * + * @see SV_WC_Payment_Gateway::get_api() + * @return WC_PayPal_Express_API API instance + * @since 2.0 + */ + protected static function get_ipn_handler( $ipn_type = 'standard' ) { + + $use_sandbox = ( 'yes' === self::get_option( 'testmode' ) ) ? true : false; + + if ( 'reference' === $ipn_type ) { + + if ( ! isset( self::$ipn_handlers['reference'] ) ) { + require_once( 'includes/class-wcs-paypal-reference-transaction-ipn-handler.php' ); + self::$ipn_handlers['reference'] = new WCS_Paypal_Reference_Transaction_IPN_Handler( $use_sandbox, self::get_option( 'receiver_email' ) ); + } + + $ipn_handler = self::$ipn_handlers['reference']; + + } else { + + if ( ! isset( self::$ipn_handlers['standard'] ) ) { + require_once( 'includes/class-wcs-paypal-standard-ipn-handler.php' ); + self::$ipn_handlers['standard'] = new WCS_Paypal_Standard_IPN_Handler( $use_sandbox, self::get_option( 'receiver_email' ) ); + } + + $ipn_handler = self::$ipn_handlers['standard']; + + } + + return $ipn_handler; + } + + /** + * Get the API object + * + * @return WCS_PayPal_Express_API API instance + * @since 2.0 + */ + public static function get_api() { + + if ( is_object( self::$api ) ) { + return self::$api; + } + + if ( ! class_exists( 'WC_Gateway_Paypal_Response' ) ) { + require_once( WC()->plugin_path() . '/includes/gateways/paypal/includes/class-wc-gateway-paypal-response.php' ); + } + + $classes = array( + 'api', + 'api-request', + 'api-response', + 'api-response-checkout', + 'api-response-billing-agreement', + 'api-response-payment', + 'api-response-recurring-payment', + ); + + foreach ( $classes as $class ) { + require_once( "includes/class-wcs-paypal-reference-transaction-{$class}.php" ); + } + + $environment = ( 'yes' === self::get_option( 'testmode' ) ) ? 'sandbox' : 'production'; + + return self::$api = new WCS_PayPal_Reference_Transaction_API( 'paypal', $environment, self::get_option( 'api_username' ), self::get_option( 'api_password' ), self::get_option( 'api_signature' ) ); + } + + /** + * Return the default WC PayPal gateway's settings. + * + * @since 2.0 + */ + public static function reload_options() { + self::get_options(); + } + + /** + * Return the default WC PayPal gateway's settings. + * + * @since 2.0 + */ + protected static function get_options() { + + self::$paypal_settings = get_option( 'woocommerce_paypal_settings' ); + + return self::$paypal_settings; + } + + /** Logging **/ + + /** + * Log API request/response data + * + * @since 2.0 + */ + public static function log_api_requests( $request_data, $response_data ) { + WC_Gateway_Paypal::log( 'Subscription Request Parameters: ' . print_r( $request_data, true ) ); + WC_Gateway_Paypal::log( 'Subscription Request Response: ' . print_r( $response_data, true ) ); + } + + /** Method required by WCS_SV_API_Base, which normally requires an instance of SV_WC_Plugin **/ + + public function get_plugin_name() { + return _x( 'WooCommerce Subscriptions PayPal', 'used in User Agent data sent to PayPal to help identify where a payment came from', 'woocommerce-subscriptions' ); + } + + public function get_version() { + return WC_Subscriptions::$version; + } + + public function get_id() { + return 'paypal'; + } + +} diff --git a/includes/gateways/paypal/includes/abstracts/abstract-wcs-sv-api-base.php b/includes/gateways/paypal/includes/abstracts/abstract-wcs-sv-api-base.php new file mode 100644 index 0000000..3332eac --- /dev/null +++ b/includes/gateways/paypal/includes/abstracts/abstract-wcs-sv-api-base.php @@ -0,0 +1,634 @@ +reset_response(); + + // save the request object + $this->request = $request; + + $start_time = microtime( true ); + + // perform the request + $response = $this->do_remote_request( $this->get_request_uri(), $this->get_request_args() ); + + // calculate request duration + $this->request_duration = round( microtime( true ) - $start_time, 5 ); + + try { + + // parse & validate response + $response = $this->handle_response( $response ); + + } catch ( Exception $e ) { + + // alert other actors that a request has been made + $this->broadcast_request(); + + throw $e; + } + + return $response; + } + + + /** + * Simple wrapper for wp_remote_request() so child classes can override this + * and provide their own transport mechanism if needed, e.g. a custom + * cURL implementation + * + * @since 2.2.0 + * @param string $request_uri + * @param string $request_args + * @return array|WP_Error + */ + protected function do_remote_request( $request_uri, $request_args ) { + return wp_safe_remote_request( $request_uri, $request_args ); + } + + + /** + * Handle and parse the response + * + * @since 2.2.0 + * @param array|WP_Error $response response data + * @throws Exception network issues, timeouts, API errors, etc + * @return object request class instance that implements SV_WC_API_Request + */ + protected function handle_response( $response ) { + + // check for WP HTTP API specific errors (network timeout, etc) + if ( is_wp_error( $response ) ) { + throw new Exception( $response->get_error_message(), (int) $response->get_error_code() ); + } + + // set response data + $this->response_code = wp_remote_retrieve_response_code( $response ); + $this->response_message = wp_remote_retrieve_response_message( $response ); + $this->response_headers = wp_remote_retrieve_headers( $response ); + $this->raw_response_body = wp_remote_retrieve_body( $response ); + + // allow child classes to validate response prior to parsing -- this is useful + // for checking HTTP status codes, etc. + $this->do_pre_parse_response_validation(); + + // parse the response body and tie it to the request + $this->response = $this->get_parsed_response( $this->raw_response_body ); + + // allow child classes to validate response after parsing -- this is useful + // for checking error codes/messages included in a parsed response + $this->do_post_parse_response_validation(); + + // fire do_action() so other actors can act on request/response data, + // primarily used for logging + $this->broadcast_request(); + + return $this->response; + } + + + /** + * Allow child classes to validate a response prior to instantiating the + * response object. Useful for checking response codes or messages, e.g. + * throw an exception if the response code is not 200. + * + * A child class implementing this method should simply return true if the response + * processing should continue, or throw a \SV_WC_API_Exception with a + * relevant error message & code to stop processing. + * + * Note: Child classes *must* sanitize the raw response body before throwing + * an exception, as it will be included in the broadcast_request() method + * which is typically used to log requests. + * + * @since 2.2.0 + */ + protected function do_pre_parse_response_validation() { + // stub method + } + + + /** + * Allow child classes to validate a response after it has been parsed + * and instantiated. This is useful for check error codes or messages that + * exist in the parsed response. + * + * A child class implementing this method should simply return true if the response + * processing should continue, or throw an Exception with a + * relevant error message & code to stop processing. + * + * Note: Response body sanitization is handled automatically + * + * @since 2.2.0 + */ + protected function do_post_parse_response_validation() { + // stub method + } + + + /** + * Return the parsed response object for the request + * + * @since 2.2.0 + * @param string $raw_response_body + * @return object response class instance which implements SV_WC_API_Request + */ + protected function get_parsed_response( $raw_response_body ) { + + $handler_class = $this->get_response_handler(); + + return new $handler_class( $raw_response_body ); + } + + + /** + * Alert other actors that a request has been performed. This is primarily used + * for request logging. + * + * @since 2.2.0 + */ + protected function broadcast_request() { + + $request_data = array( + 'method' => $this->get_request_method(), + 'uri' => $this->get_request_uri(), + 'user-agent' => $this->get_request_user_agent(), + 'headers' => $this->get_sanitized_request_headers(), + 'body' => $this->request->to_string_safe(), + 'duration' => $this->get_request_duration() . 's', // seconds + ); + + $response_data = array( + 'code' => $this->get_response_code(), + 'message' => $this->get_response_message(), + 'headers' => $this->get_response_headers(), + 'body' => $this->get_sanitized_response_body() ? $this->get_sanitized_response_body() : $this->get_raw_response_body(), + ); + + do_action( 'wc_' . $this->get_api_id() . '_api_request_performed', $request_data, $response_data, $this ); + } + + + /** + * Reset the API response members to their + * + * @since 1.0.0 + */ + protected function reset_response() { + + $this->response_code = null; + $this->response_message = null; + $this->response_headers = null; + $this->raw_response_body = null; + $this->response = null; + $this->request_duration = null; + } + + + /** Request Getters *******************************************************/ + + + /** + * Get the request URI + * + * @since 2.2.0 + * @return string + */ + protected function get_request_uri() { + + // API base request URI + any request-specific path + $uri = $this->request_uri . ( $this->get_request() ? $this->get_request()->get_path() : '' ); + + /** + * Request URI Filter. + * + * Allow actors to filter the request URI. Note that child classes can override + * this method, which means this filter may be invoked prior to the overridden + * method. + * + * @since 4.1.0 + * @param string $uri current request URI + * @param \WCS_SV_API_Base class instance + */ + return apply_filters( 'wc_' . $this->get_api_id() . '_api_request_uri', $uri, $this ); + } + + + /** + * Get the request arguments in the format required by wp_remote_request() + * + * @since 2.2.0 + * @return mixed|void + */ + protected function get_request_args() { + + $args = array( + 'method' => $this->get_request_method(), + 'timeout' => MINUTE_IN_SECONDS, + 'redirection' => 0, + 'httpversion' => $this->get_request_http_version(), + 'sslverify' => true, + 'blocking' => true, + 'user-agent' => $this->get_request_user_agent(), + 'headers' => $this->get_request_headers(), + 'body' => $this->get_request()->to_string(), + 'cookies' => array(), + ); + + /** + * Request arguments. + * + * Allow other actors to filter the request arguments. Note that + * child classes can override this method, which means this filter may + * not be invoked, or may be invoked prior to the overridden method + * + * @since 2.2.0 + * @param array $args request arguments + * @param \WCS_SV_API_Base class instance + */ + return apply_filters( 'wc_' . $this->get_api_id() . '_http_request_args', $args, $this ); + } + + + /** + * Get the request method, POST by default + * + * @since 2.2.0 + * @return string + */ + protected function get_request_method() { + // if the request object specifies the method to use, use that, otherwise use the API default + return $this->get_request() && $this->get_request()->get_method() ? $this->get_request()->get_method() : $this->request_method; + } + + + /** + * Get the request HTTP version, 1.1 by default + * + * @since 2.2.0 + * @return string + */ + protected function get_request_http_version() { + + return $this->request_http_version; + } + + + /** + * Get the request headers + * + * @since 2.2.0 + * @return array + */ + protected function get_request_headers() { + return $this->request_headers; + } + + + /** + * Get sanitized request headers suitable for logging, stripped of any + * confidential information + * + * The `Authorization` header is sanitized automatically. + * + * Child classes that implement any custom authorization headers should + * override this method to perform sanitization. + * + * @since 2.2.0 + * @return array + */ + protected function get_sanitized_request_headers() { + + $headers = $this->get_request_headers(); + + if ( ! empty( $headers['Authorization'] ) ) { + $headers['Authorization'] = str_repeat( '*', strlen( $headers['Authorization'] ) ); + } + + return $headers; + } + + + /** + * Get the request user agent, defaults to: + * + * Dasherized-Plugin-Name/Plugin-Version (WooCommerce/WC-Version; WordPress/WP-Version) + * + * @since 2.2.0 + * @return string + */ + protected function get_request_user_agent() { + + return sprintf( '%s/%s (WooCommerce/%s; WordPress/%s)', str_replace( ' ', '-', $this->get_plugin()->get_plugin_name() ), $this->get_plugin()->get_version(), WC_VERSION, $GLOBALS['wp_version'] ); + } + + + /** + * Get the request duration in seconds, rounded to the 5th decimal place + * + * @since 2.2.0 + * @return string + */ + protected function get_request_duration() { + return $this->request_duration; + } + + + /** Response Getters ******************************************************/ + + + /** + * Get the response handler class name + * + * @since 2.2.0 + * @return string + */ + protected function get_response_handler() { + return $this->response_handler; + } + + + /** + * Get the response code + * + * @since 2.2.0 + * @return string + */ + protected function get_response_code() { + return $this->response_code; + } + + + /** + * Get the response message + * + * @since 2.2.0 + * @return string + */ + protected function get_response_message() { + return $this->response_message; + } + + + /** + * Get the response headers + * + * @since 2.2.0 + * @return array + */ + protected function get_response_headers() { + return $this->response_headers; + } + + + /** + * Get the raw response body, prior to any parsing or sanitization + * + * @since 2.2.0 + * @return string + */ + protected function get_raw_response_body() { + return $this->raw_response_body; + } + + + /** + * Get the sanitized response body, provided by the response class + * to_string_safe() method + * + * @since 2.2.0 + * @return string|null + */ + protected function get_sanitized_response_body() { + return is_callable( array( $this->get_response(), 'to_string_safe' ) ) ? $this->get_response()->to_string_safe() : null; + } + + + /** Misc Getters ******************************************************/ + + + /** + * Returns the most recent request object + * + * @since 2.2.0 + * @see \SV_WC_API_Request + * @return object the most recent request object + */ + public function get_request() { + return $this->request; + } + + + /** + * Returns the most recent response object + * + * @since 2.2.0 + * @see \SV_WC_API_Response + * @return object the most recent response object + */ + public function get_response() { + return $this->response; + } + + + /** + * Get the ID for the API, used primarily to namespace the action name + * for broadcasting requests + * + * @since 2.2.0 + * @return string + */ + protected function get_api_id() { + + return $this->get_plugin()->get_id(); + } + + + /** + * Return a new request object + * + * Child classes must implement this to return an object that implements + * \SV_WC_API_Request which should be used in the child class API methods + * to build the request. The returned SV_WC_API_Request should be passed + * to self::perform_request() by your concrete API methods + * + * @since 2.2.0 + * @param array $args optional request arguments + * @return SV_WC_API_Request + */ + abstract protected function get_new_request( $args = array() ); + + + /** + * Return the plugin class instance associated with this API + * + * Child classes must implement this to return their plugin class instance + * + * This is used for defining the plugin ID used in filter names, as well + * as the plugin name used for the default user agent. + * + * @since 2.2.0 + * @return SV_WC_Plugin + */ + abstract protected function get_plugin(); + + + /** Setters ***************************************************************/ + + + /** + * Set a header request + * + * @since 2.2.0 + * @param string $name header name + * @param string $value header value + * @return string + */ + protected function set_request_header( $name, $value ) { + + $this->request_headers[ $name ] = $value; + } + + + /** + * Set HTTP basic auth for the request + * + * Since 2.2.0 + * @param string $username + * @param string $password + */ + protected function set_http_basic_auth( $username, $password ) { + + $this->request_headers['Authorization'] = sprintf( 'Basic %s', base64_encode( "{$username}:{$password}" ) ); + } + + + /** + * Set the Content-Type request header + * + * @since 2.2.0 + * @param string $content_type + */ + protected function set_request_content_type_header( $content_type ) { + $this->request_headers['content-type'] = $content_type; + } + + + /** + * Set the Accept request header + * + * @since 2.2.0 + * @param string $type the request accept type + */ + protected function set_request_accept_header( $type ) { + $this->request_headers['accept'] = $type; + } + + + /** + * Set the response handler class name. This class will be instantiated + * to parse the response for the request. + * + * Note the class should implement SV_WC_API + * + * @since 2.2.0 + * @param string $handler handle class name + * @return array + */ + protected function set_response_handler( $handler ) { + $this->response_handler = $handler; + } + + +} diff --git a/includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php b/includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php new file mode 100644 index 0000000..7252ce1 --- /dev/null +++ b/includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php @@ -0,0 +1,215 @@ +payment_gateways->payment_gateways as $key => $gateway ) { + + if ( WC()->payment_gateways->payment_gateways[ $key ]->id !== 'paypal' ) { + continue; + } + + // Warn store managers not to change their PayPal Email address as it can break existing Subscriptions in WC2.0+ + WC()->payment_gateways->payment_gateways[ $key ]->form_fields['receiver_email']['desc_tip'] = false; + WC()->payment_gateways->payment_gateways[ $key ]->form_fields['receiver_email']['description'] .= '

    ' . __( 'It is strongly recommended you do not change the Receiver Email address if you have active subscriptions with PayPal. Doing so can break existing subscriptions.', 'woocommerce-subscriptions' ); + } + } + + /** + * Handle requests to check whether a PayPal account has Reference Transactions enabled + * + * @since 2.0 + */ + public static function maybe_check_account() { + + if ( isset( $_GET['wcs_paypal'] ) && 'check_reference_transaction_support' === $_GET['wcs_paypal'] && wp_verify_nonce( $_GET['_wpnonce'], __CLASS__ ) ) { + + $redirect_url = remove_query_arg( array( 'wcs_paypal', '_wpnonce' ) ); + + if ( WCS_PayPal::are_reference_transactions_enabled( 'bypass_cache' ) ) { + $redirect_url = add_query_arg( array( 'wcs_paypal' => 'rt_enabled' ), $redirect_url ); + } else { + $redirect_url = add_query_arg( array( 'wcs_paypal' => 'rt_not_enabled' ), $redirect_url ); + } + + wp_safe_redirect( $redirect_url ); + } + } + + /** + * Display an assortment of notices to administrators to encourage them to get PayPal setup right. + * + * @since 2.0 + */ + public static function maybe_show_admin_notices() { + + self::maybe_disable_invalid_profile_notice(); + + self::maybe_update_credentials_error_flag(); + + if ( ! in_array( get_woocommerce_currency(), apply_filters( 'woocommerce_paypal_supported_currencies', array( 'AUD', 'BRL', 'CAD', 'MXN', 'NZD', 'HKD', 'SGD', 'USD', 'EUR', 'JPY', 'TRY', 'NOK', 'CZK', 'DKK', 'HUF', 'ILS', 'MYR', 'PHP', 'PLN', 'SEK', 'CHF', 'TWD', 'THB', 'GBP', 'RMB' ) ) ) ) { + $valid_for_use = false; + } else { + $valid_for_use = true; + } + + $payment_gateway_tab_url = admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=wc_gateway_paypal' ); + + $notices = array(); + + if ( $valid_for_use && 'yes' == WCS_PayPal::get_option( 'enabled' ) && ! has_action( 'admin_notices', 'WC_Subscriptions_Admin::admin_installed_notice' ) && current_user_can( 'manage_options' ) ) { + + if ( ! WCS_PayPal::are_credentials_set() ) { + + $notices[] = array( + 'type' => 'warning', + // translators: placeholders are opening and closing link tags. 1$-2$: to docs on woothemes, 3$-4$ to gateway settings on the site + 'text' => sprintf( esc_html__( 'PayPal is inactive for subscription transactions. Please %1$sset up the PayPal IPN%2$s and %3$senter your API credentials%4$s to enable PayPal for Subscriptions.', 'woocommerce-subscriptions' ), + '', + '', + '', + '' + ), + ); + + } elseif ( 'woocommerce_page_wc-settings' === get_current_screen()->base && isset( $_GET['tab'] ) && in_array( $_GET['tab'], array( 'subscriptions', 'checkout' ) ) && ! WCS_PayPal::are_reference_transactions_enabled() ) { + + $notices[] = array( + 'type' => 'warning', + // translators: placeholders are opening and closing strong and link tags. 1$-2$: strong tags, 3$-8$ link to docs on woothemes + 'text' => sprintf( esc_html__( '%1$sPayPal Reference Transactions are not enabled on your account%2$s, some subscription management features are not enabled. Please contact PayPal and request they %3$senable PayPal Reference Transactions%4$s on your account. %5$sCheck PayPal Account%6$s %7$sLearn more %8$s', 'woocommerce-subscriptions' ), + '', + '', + '', + '', + '

    ', + '', + '', + '»' + ), + ); + + } + + if ( isset( $_GET['wcs_paypal'] ) && 'rt_enabled' === $_GET['wcs_paypal'] ) { + $notices[] = array( + 'type' => 'confirmation', + // translators: placeholders are opening and closing strong tags. + 'text' => sprintf( esc_html__( '%1$sPayPal Reference Transactions are enabled on your account%2$s. All subscription management features are now enabled. Happy selling!', 'woocommerce-subscriptions' ), + '', + '' + ), + ); + } + + if ( false !== get_option( 'wcs_paypal_credentials_error' ) ) { + $notices[] = array( + 'type' => 'error', + // translators: placeholders are link opening and closing tags. 1$-2$: to gateway settings, 3$-4$: support docs on woothemes.com + 'text' => sprintf( esc_html__( 'There is a problem with PayPal. Your API credentials may be incorrect. Please update your %1$sAPI credentials%2$s. %3$sLearn more%4$s.', 'woocommerce-subscriptions' ), + '', + '', + '', + '' + ), + ); + } + + if ( 'yes' == get_option( 'wcs_paypal_invalid_profile_id' ) ) { + $notices[] = array( + 'type' => 'error', + // translators: placeholders are opening and closing link tags. 1$-2$: docs on woothemes, 3$-4$: dismiss link + 'text' => sprintf( esc_html__( 'There is a problem with PayPal. Your PayPal account is issuing out-of-date subscription IDs. %1$sLearn more%2$s. %3$sDismiss%4$s.', 'woocommerce-subscriptions' ), + '', + '', + '', + '' + ), + ); + } + } + + if ( ! empty( $notices ) ) { + include_once( dirname( __FILE__ ) . '/../templates/admin-notices.php' ); + } + } + + /** + * Disable the invalid profile notice when requested. + * + * @since 2.0 + */ + protected static function maybe_disable_invalid_profile_notice() { + if ( isset( $_GET['wcs_disable_paypal_invalid_profile_id_notice'] ) ) { + update_option( 'wcs_paypal_invalid_profile_id', 'disabled' ); + } + } + + /** + * Remove the invalid credentials error flag whenever a new set of API credentials are saved. + * + * @since 2.0 + */ + protected static function maybe_update_credentials_error_flag() { + + // Check if the API credentials are being saved - we can't do this on the 'woocommerce_update_options_payment_gateways_paypal' hook because it is triggered after 'admin_notices' + if ( ! empty( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( $_REQUEST['_wpnonce'], 'woocommerce-settings' ) && isset( $_POST['woocommerce_paypal_api_username'] ) || isset( $_POST['woocommerce_paypal_api_password'] ) || isset( $_POST['woocommerce_paypal_api_signature'] ) ) { + + $credentials_updated = false; + + if ( isset( $_POST['woocommerce_paypal_api_username'] ) && WCS_PayPal::get_option( 'api_username' ) != $_POST['woocommerce_paypal_api_username'] ) { + $credentials_updated = true; + } elseif ( isset( $_POST['woocommerce_paypal_api_password'] ) && WCS_PayPal::get_option( 'api_password' ) != $_POST['woocommerce_paypal_api_password'] ) { + $credentials_updated = true; + } elseif ( isset( $_POST['woocommerce_paypal_api_signature'] ) && WCS_PayPal::get_option( 'api_signature' ) != $_POST['woocommerce_paypal_api_signature'] ) { + $credentials_updated = true; + } + + if ( $credentials_updated ) { + delete_option( 'wcs_paypal_credentials_error' ); + } + } + + do_action( 'wcs_paypal_admin_update_credentials' ); + } + +} diff --git a/includes/gateways/paypal/includes/admin/class-wcs-paypal-change-payment-method-admin.php b/includes/gateways/paypal/includes/admin/class-wcs-paypal-change-payment-method-admin.php new file mode 100644 index 0000000..4b66f20 --- /dev/null +++ b/includes/gateways/paypal/includes/admin/class-wcs-paypal-change-payment-method-admin.php @@ -0,0 +1,84 @@ +id, '_paypal_subscription_id', true ); + + if ( wcs_is_paypal_profile_a( $subscription_id, 'billing_agreement' ) || empty( $subscription_id ) ) { + $label = 'PayPal Billing Agreement ID'; + $disabled = false; + } else { + $label = 'PayPal Standard Subscription ID'; + $disabled = true; + } + + $payment_meta['paypal'] = array( + 'post_meta' => array( + '_paypal_subscription_id' => array( + 'value' => $subscription_id, + 'label' => $label, + 'disabled' => $disabled, + ), + ), + ); + + return $payment_meta; + } + + /** + * Validate the payment meta data required to process automatic recurring payments so that store managers can + * manually set up automatic recurring payments for a customer via the Edit Subscription screen. + * + * @param string $payment_method_id The ID of the payment method to validate + * @param array $payment_meta associative array of meta data required for automatic payments + * @return array + * @since 2.0 + */ + public static function validate_payment_meta( $payment_meta, $subscription ) { + if ( empty( $payment_meta['post_meta']['_paypal_subscription_id']['value'] ) ) { + throw new Exception( 'A valid PayPal Billing Agreement ID value is required.' ); + } elseif ( $subscription->paypal_subscription_id !== $payment_meta['post_meta']['_paypal_subscription_id']['value'] && 0 !== strpos( $payment_meta['post_meta']['_paypal_subscription_id']['value'], 'B-' ) ) { + throw new Exception( 'Invalid Billing Agreemend ID. A valid PayPal Billing Agreement ID must begin with "B-".' ); + } + } + +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php new file mode 100644 index 0000000..70c6c9e --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php @@ -0,0 +1,659 @@ +add_parameters( array( + 'USER' => $api_username, + 'PWD' => $api_password, + 'SIGNATURE' => $api_signature, + 'VERSION' => $api_version, + ) ); + } + + /** + * Sets up the express checkout transaction + * + * @link https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECGettingStarted/#id084RN060BPF + * @link https://developer.paypal.com/webapps/developer/docs/classic/api/merchant/SetExpressCheckout_API_Operation_NVP/ + * + * @param array $args { + * @type string 'currency' (Optional) A 3-character currency code (default is store's currency). + * @type string 'billing_type' (Optional) Type of billing agreement for reference transactions. You must have permission from PayPal to use this field. This field must be set to one of the following values: MerchantInitiatedBilling - PayPal creates a billing agreement for each transaction associated with buyer. You must specify version 54.0 or higher to use this option; MerchantInitiatedBillingSingleAgreement - PayPal creates a single billing agreement for all transactions associated with buyer. Use this value unless you need per-transaction billing agreements. You must specify version 58.0 or higher to use this option. + * @type string 'billing_description' (Optional) Description of goods or services associated with the billing agreement. This field is required for each recurring payment billing agreement if using MerchantInitiatedBilling as the billing type, that means you can use a different agreement for each subscription/order. PayPal recommends that the description contain a brief summary of the billing agreement terms and conditions (but this only makes sense when the billing type is MerchantInitiatedBilling, otherwise the terms will be incorrectly displayed for all agreements). For example, buyer is billed at "9.99 per month for 2 years". + * @type string 'maximum_amount' (Optional) The expected maximum total amount of the complete order and future payments, including shipping cost and tax charges. If you pass the expected average transaction amount (default 25.00). PayPal uses this value to validate the buyer's funding source. + * @type string 'no_shipping' (Optional) Determines where or not PayPal displays shipping address fields on the PayPal pages. For digital goods, this field is required, and you must set it to 1. It is one of the following values: 0 – PayPal displays the shipping address on the PayPal pages; 1 – PayPal does not display shipping address fields whatsoever (default); 2 – If you do not pass the shipping address, PayPal obtains it from the buyer's account profile. + * @type string 'page_style' (Optional) Name of the Custom Payment Page Style for payment pages associated with this button or link. It corresponds to the HTML variable page_style for customizing payment pages. It is the same name as the Page Style Name you chose to add or edit the page style in your PayPal Account profile. + * @type string 'brand_name' (Optional) A label that overrides the business name in the PayPal account on the PayPal hosted checkout pages. Default: store name. + * @type string 'landing_page' (Optional) Type of PayPal page to display. It is one of the following values: 'login' – PayPal account login (default); 'Billing' – Non-PayPal account. + * @type string 'payment_action' (Optional) How you want to obtain payment. If the transaction does not include a one-time purchase, this field is ignored. Default 'Sale' – This is a final sale for which you are requesting payment (default). Alternative: 'Authorization' – This payment is a basic authorization subject to settlement with PayPal Authorization and Capture. You cannot set this field to Sale in SetExpressCheckout request and then change the value to Authorization or Order in the DoExpressCheckoutPayment request. If you set the field to Authorization or Order in SetExpressCheckout, you may set the field to Sale. + * @type string 'return_url' (Required) URL to which the buyer's browser is returned after choosing to pay with PayPal. + * @type string 'cancel_url' (Required) URL to which the buyer is returned if the buyer does not approve the use of PayPal to pay you. + * @type string 'custom' (Optional) A free-form field for up to 256 single-byte alphanumeric characters + * } + * @since 2.0 + */ + public function set_express_checkout( $args ) { + + // translators: placeholder is blogname + $default_description = sprintf( _x( 'Orders with %s', 'data sent to paypal', 'woocommerce-subscriptions' ), get_bloginfo( 'name' ) ); + + $defaults = array( + 'currency' => get_woocommerce_currency(), + 'billing_type' => apply_filters( 'woocommerce_subscriptions_paypal_billing_agreement_type', 'MerchantInitiatedBillingSingleAgreement', $args ), + // translators: placeholder is for blog name + 'billing_description' => html_entity_decode( apply_filters( 'woocommerce_subscriptions_paypal_billing_agreement_description', $default_description, $args ), ENT_NOQUOTES, 'UTF-8' ), + 'maximum_amount' => null, + 'no_shipping' => 1, + 'page_style' => null, + 'brand_name' => html_entity_decode( get_bloginfo( 'name' ), ENT_NOQUOTES, 'UTF-8' ), + 'landing_page' => 'login', + 'payment_action' => 'Sale', + 'custom' => '', + ); + + $args = wp_parse_args( $args, $defaults ); + + $this->set_method( 'SetExpressCheckout' ); + + $this->add_parameters( array( + 'L_BILLINGTYPE0' => $args['billing_type'], + 'L_BILLINGAGREEMENTDESCRIPTION0' => wcs_get_paypal_item_name( $args['billing_description'] ), + 'L_BILLINGAGREEMENTCUSTOM0' => $args['custom'], + + 'RETURNURL' => $args['return_url'], + 'CANCELURL' => $args['cancel_url'], + 'PAGESTYLE' => $args['page_style'], + 'BRANDNAME' => $args['brand_name'], + 'LANDINGPAGE' => ( 'login' == $args['landing_page'] ) ? 'Login' : 'Billing', + 'NOSHIPPING' => $args['no_shipping'], + + 'MAXAMT' => $args['maximum_amount'], + ) ); + + // if we have an order, the request is to create a subscription/process a payment (not just check if the PayPal account supports Reference Transactions) + if ( isset( $args['order'] ) ) { + + if ( 0 == $args['order']->get_total() ) { + + $this->add_parameters( array( + 'PAYMENTREQUEST_0_AMT' => 0, // a zero amount is use so that no DoExpressCheckout action is required and instead CreateBillingAgreement is used to first create a billing agreement not attached to any order and then DoReferenceTransaction is used to charge both the initial order and renewal order amounts + 'PAYMENTREQUEST_0_ITEMAMT' => 0, + 'PAYMENTREQUEST_0_SHIPPINGAMT' => 0, + 'PAYMENTREQUEST_0_TAXAMT' => 0, + 'PAYMENTREQUEST_0_CURRENCYCODE' => $args['currency'], + 'PAYMENTREQUEST_0_CUSTOM' => $args['custom'], + 'PAYMENTREQUEST_0_PAYMENTACTION' => $args['payment_action'], + ) ); + + } else { + + $this->add_payment_details_parameters( $args['order'], $args['payment_action'] ); + + } + } + } + + /** + * Set up the DoExpressCheckoutPayment request + * + * @link https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECGettingStarted/#id084RN060BPF + * @link https://developer.paypal.com/webapps/developer/docs/classic/api/merchant/DoExpressCheckoutPayment_API_Operation_NVP/ + * + * @since 2.0.9 + * @param string $token PayPal Express Checkout token returned by SetExpressCheckout operation + * @param WC_Order $order order object + * @param string $type + */ + public function do_express_checkout( $token, WC_Order $order, $args ) { + + $this->set_method( 'DoExpressCheckoutPayment' ); + + // set base params + $this->add_parameters( array( + 'TOKEN' => $token, + 'PAYERID' => $args['payer_id'], + 'BUTTONSOURCE' => 'WooThemes_Cart', + 'RETURNFMFDETAILS' => 1, + ) ); + + $this->add_payment_details_parameters( $order, $args['payment_action'] ); + } + + /** + * Get info about the buyer & transaction from PayPal + * + * @link https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECGettingStarted/#id084RN060BPF + * @link https://developer.paypal.com/webapps/developer/docs/classic/api/merchant/GetExpressCheckoutDetails_API_Operation_NVP/ + * + * @param string $token token from SetExpressCheckout response + * @since 2.0 + */ + public function get_express_checkout_details( $token ) { + + $this->set_method( 'GetExpressCheckoutDetails' ); + $this->add_parameter( 'TOKEN', $token ); + } + + /** + * Create a billing agreement, required when a subscription sign-up has no initial payment + * + * @link https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECReferenceTxns/#id094TB0Y0J5Z__id094TB4003HS + * @link https://developer.paypal.com/docs/classic/api/merchant/CreateBillingAgreement_API_Operation_NVP/ + * + * @param string $token token from SetExpressCheckout response + * @since 2.0 + */ + public function create_billing_agreement( $token ) { + + $this->set_method( 'CreateBillingAgreement' ); + $this->add_parameter( 'TOKEN', $token ); + } + + /** + * Charge a payment against a reference token + * + * @link https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECReferenceTxns/#id094UM0DA0HS + * @link https://developer.paypal.com/docs/classic/api/merchant/DoReferenceTransaction_API_Operation_NVP/ + * + * @param string $reference_id the ID of a refrence object, e.g. billing agreement ID. + * @param WC_Order $order order object + * @param array $args { + * @type string 'payment_type' (Optional) Specifies type of PayPal payment you require for the billing agreement. It is one of the following values. 'Any' or 'InstantOnly'. Echeck is not supported for DoReferenceTransaction requests. + * @type string 'payment_action' How you want to obtain payment. It is one of the following values: 'Authorization' - this payment is a basic authorization subject to settlement with PayPal Authorization and Capture; or 'Sale' - This is a final sale for which you are requesting payment. + * @type string 'return_fraud_filters' (Optional) Flag to indicate whether you want the results returned by Fraud Management Filters. By default, you do not receive this information. + * } + * @since 2.0 + */ + public function do_reference_transaction( $reference_id, $order, $args = array() ) { + + $defaults = array( + 'amount' => $order->get_total(), + 'payment_type' => 'Any', + 'payment_action' => 'Sale', + 'return_fraud_filters' => 1, + 'notify_url' => WC()->api_request_url( 'WC_Gateway_Paypal' ), + 'invoice_number' => WCS_PayPal::get_option( 'invoice_prefix' ) . wcs_str_to_ascii( ltrim( $order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) ), + 'custom' => wcs_json_encode( array( 'order_id' => $order->id, 'order_key' => $order->order_key ) ), + ); + + $args = wp_parse_args( $args, $defaults ); + + $this->set_method( 'DoReferenceTransaction' ); + + // set base params + $this->add_parameters( array( + 'REFERENCEID' => $reference_id, + 'BUTTONSOURCE' => 'WooThemes_Cart', + 'RETURNFMFDETAILS' => $args['return_fraud_filters'], + 'NOTIFYURL' => $args['notify_url'], + ) ); + + $this->add_payment_details_parameters( $order, $args['payment_action'], true ); + } + + /** + * Set up the payment details for a DoExpressCheckoutPayment or DoReferenceTransaction request + * + * @since 2.0.9 + * @param WC_Order $order order object + * @param string $type the type of transaction for the payment + * @param bool $use_deprecated_params whether to use deprecated PayPal NVP parameters (required for DoReferenceTransaction API calls) + */ + protected function add_payment_details_parameters( WC_Order $order, $type, $use_deprecated_params = false ) { + + $calculated_total = 0; + $order_subtotal = 0; + $item_count = 0; + $order_items = array(); + + // add line items + foreach ( $order->get_items() as $item ) { + + $product = new WC_Product( $item['product_id'] ); + + $order_items[] = array( + 'NAME' => wcs_get_paypal_item_name( $product->get_title() ), + 'DESC' => $this->get_item_description( $item, $product ), + 'AMT' => $this->round( $order->get_item_subtotal( $item ) ), + 'QTY' => ( ! empty( $item['qty'] ) ) ? absint( $item['qty'] ) : 1, + 'ITEMURL' => $product->get_permalink(), + ); + + $order_subtotal += $item['line_total']; + } + + // add fees + foreach ( $order->get_fees() as $fee ) { + + $order_items[] = array( + 'NAME' => wcs_get_paypal_item_name( $fee['name'] ), + 'AMT' => $this->round( $fee['line_total'] ), + 'QTY' => 1, + ); + + $order_subtotal += $fee['line_total']; + } + + // add discounts + if ( $order->get_total_discount() > 0 ) { + + $order_items[] = array( + 'NAME' => __( 'Total Discount', 'woocommerce-subscriptions' ), + 'QTY' => 1, + 'AMT' => - $this->round( $order->get_total_discount() ), + ); + } + + if ( $this->skip_line_items( $order ) ) { + + $total_amount = $this->round( $order->get_total() ); + + // calculate the total as PayPal would + $calculated_total += $this->round( $order_subtotal + $order->get_cart_tax() ) + $this->round( $order->get_total_shipping() + $order->get_shipping_tax() ); + + // offset the discrepency between the WooCommerce cart total and PayPal's calculated total by adjusting the order subtotal + if ( $total_amount !== $calculated_total ) { + $order_subtotal = $order_subtotal - ( $calculated_total - $total_amount ); + } + + $item_names = array(); + + foreach ( $order_items as $item ) { + + $item_names[] = sprintf( '%1$s x %2$s', $item['NAME'], $item['QTY'] ); + } + + // add a single item for the entire order + $this->add_line_item_parameters( array( + // translators: placeholder is blogname + 'NAME' => sprintf( __( '%s - Order', 'woocommerce-subscriptions' ), get_option( 'blogname' ) ), + 'DESC' => wcs_get_paypal_item_name( implode( ', ', $item_names ) ), + 'AMT' => $this->round( $order_subtotal + $order->get_cart_tax() ), + 'QTY' => 1, + ), 0, $use_deprecated_params ); + + // add order-level parameters + // - Do not sent the TAXAMT due to rounding errors + if ( $use_deprecated_params ) { + $this->add_parameters( array( + 'AMT' => $total_amount, + 'CURRENCYCODE' => $order->get_order_currency(), + 'ITEMAMT' => $this->round( $order_subtotal + $order->get_cart_tax() ), + 'SHIPPINGAMT' => $this->round( $order->get_total_shipping() + $order->get_shipping_tax() ), + 'INVNUM' => WCS_PayPal::get_option( 'invoice_prefix' ) . wcs_str_to_ascii( ltrim( $order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) ), + 'PAYMENTACTION' => $type, + 'PAYMENTREQUESTID' => $order->id, + 'CUSTOM' => json_encode( array( 'order_id' => $order->id, 'order_key' => $order->order_key ) ), + ) ); + } else { + $this->add_payment_parameters( array( + 'AMT' => $total_amount, + 'CURRENCYCODE' => $order->get_order_currency(), + 'ITEMAMT' => $this->round( $order_subtotal + $order->get_cart_tax() ), + 'SHIPPINGAMT' => $this->round( $order->get_total_shipping() + $order->get_shipping_tax() ), + 'INVNUM' => WCS_PayPal::get_option( 'invoice_prefix' ) . wcs_str_to_ascii( ltrim( $order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) ), + 'PAYMENTACTION' => $type, + 'PAYMENTREQUESTID' => $order->id, + 'CUSTOM' => json_encode( array( 'order_id' => $order->id, 'order_key' => $order->order_key ) ), + ) ); + } + } else { + + // add individual order items + foreach ( $order_items as $item ) { + + $this->add_line_item_parameters( $item, $item_count++, $use_deprecated_params ); + $calculated_total += $this->round( $item['AMT'] * $item['QTY'] ); + } + + // add shipping and tax to calculated total + $calculated_total += $this->round( $order->get_total_shipping() ) + $this->round( $order->get_total_tax() ); + + $total_amount = $this->round( $order->get_total() ); + + // add order-level parameters + if ( $use_deprecated_params ) { + $this->add_parameters( array( + 'AMT' => $total_amount, + 'CURRENCYCODE' => $order->get_order_currency(), + 'ITEMAMT' => $this->round( $order_subtotal ), + 'SHIPPINGAMT' => $this->round( $order->get_total_shipping() ), + 'TAXAMT' => $this->round( $order->get_total_tax() ), + 'INVNUM' => WCS_PayPal::get_option( 'invoice_prefix' ) . wcs_str_to_ascii( ltrim( $order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) ), + 'PAYMENTACTION' => $type, + 'PAYMENTREQUESTID' => $order->id, + 'CUSTOM' => json_encode( array( 'order_id' => $order->id, 'order_key' => $order->order_key ) ), + ) ); + } else { + $this->add_payment_parameters( array( + 'AMT' => $total_amount, + 'CURRENCYCODE' => $order->get_order_currency(), + 'ITEMAMT' => $this->round( $order_subtotal ), + 'SHIPPINGAMT' => $this->round( $order->get_total_shipping() ), + 'TAXAMT' => $this->round( $order->get_total_tax() ), + 'INVNUM' => WCS_PayPal::get_option( 'invoice_prefix' ) . wcs_str_to_ascii( ltrim( $order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) ), + 'PAYMENTACTION' => $type, + 'PAYMENTREQUESTID' => $order->id, + 'CUSTOM' => json_encode( array( 'order_id' => $order->id, 'order_key' => $order->order_key ) ), + ) ); + } + + // offset the discrepency between the WooCommerce cart total and PayPal's calculated total by adjusting the cost of the first item + if ( $total_amount !== $calculated_total ) { + $this->parameters['L_PAYMENTREQUEST_0_AMT0'] = $this->parameters['L_PAYMENTREQUEST_0_AMT0'] - ( $calculated_total - $total_amount ); + } + } + } + + /** + * Performs an Express Checkout NVP API operation as passed in $api_method. + * + * Although the PayPal Standard API provides no facility for cancelling a subscription, the PayPal + * Express Checkout NVP API can be used. + * + * @since 2.0 + */ + public function manage_recurring_payments_profile_status( $profile_id, $new_status, $order = null ) { + + $this->set_method( 'ManageRecurringPaymentsProfileStatus' ); + + // We need to get merge the existing params to ensure the method and API credentials are passed to the filter for backward compatibility + $this->add_parameters( apply_filters( 'woocommerce_subscriptions_paypal_change_status_data', array_merge( $this->get_parameters(), array( + 'PROFILEID' => $profile_id, + 'ACTION' => $new_status, + // translators: 1$: new status (e.g. "Cancel"), 2$: blog name + 'NOTE' => html_entity_decode( sprintf( _x( '%1$s subscription event triggered at %2$s', 'data sent to paypal', 'woocommerce-subscriptions' ), $new_status, get_bloginfo( 'name' ) ), ENT_NOQUOTES, 'UTF-8' ), + ) ), $new_status, $order, $profile_id ) ); + } + + /** Helper Methods ******************************************************/ + + /** + * Add a parameter + * + * @param string $key + * @param string|int $value + * @since 2.0 + */ + private function add_parameter( $key, $value ) { + $this->parameters[ $key ] = $value; + } + + /** + * Add multiple parameters + * + * @param array $params + * @since 2.0 + */ + private function add_parameters( array $params ) { + foreach ( $params as $key => $value ) { + $this->add_parameter( $key, $value ); + } + } + + /** + * Set the method for the request, currently using: + * + * + `SetExpressCheckout` - setup transaction + * + `GetExpressCheckout` - gets buyers info from PayPal + * + `DoExpressCheckoutPayment` - completes the transaction + * + `DoCapture` - captures a previously authorized transaction + * + * @param string $method + * @since 2.0 + */ + private function set_method( $method ) { + $this->add_parameter( 'METHOD', $method ); + } + + /** + * Add payment parameters, auto-prefixes the parameter key with `PAYMENTREQUEST_0_` + * for convenience and readability + * + * @param array $params + * @since 2.0 + */ + private function add_payment_parameters( array $params ) { + foreach ( $params as $key => $value ) { + $this->add_parameter( "PAYMENTREQUEST_0_{$key}", $value ); + } + } + + /** + * Adds a line item parameters to the request, auto-prefixes the parameter key + * with `L_PAYMENTREQUEST_0_` for convenience and readability + * + * @param array $params + * @param int $item_count current item count + * @since 2.0 + */ + private function add_line_item_parameters( array $params, $item_count, $use_deprecated_params = false ) { + foreach ( $params as $key => $value ) { + if ( $use_deprecated_params ) { + $this->add_parameter( "L_{$key}{$item_count}", $value ); + } else { + $this->add_parameter( "L_PAYMENTREQUEST_0_{$key}{$item_count}", $value ); + } + } + } + + /** + * Helper method to return the item description, which is composed of item + * meta flattened into a comma-separated string, if available. Otherwise the + * product SKU is included. + * + * The description is automatically truncated to the 127 char limit. + * + * @param array $item cart or order item + * @param \WC_Product $product product data + * @return string + * @since 2.0 + */ + private function get_item_description( $item, $product ) { + + if ( empty( $item['item_meta'] ) ) { + + // cart item + $item_desc = WC()->cart->get_item_data( $item, true ); + + $item_desc = str_replace( "\n", ', ', rtrim( $item_desc ) ); + + } else { + + // order item + $item_meta = new WC_Order_Item_Meta( $item ); + + $item_meta = $item_meta->get_formatted(); + + if ( ! empty( $item_meta ) ) { + + $item_desc = array(); + + foreach ( $item_meta as $meta ) { + $item_desc[] = sprintf( '%s: %s', $meta['label'], $meta['value'] ); + } + + $item_desc = implode( ', ', $item_desc ); + + } else { + + $item_desc = is_callable( array( $product, 'get_sku' ) ) && $product->get_sku() ? sprintf( __( 'SKU: %s', 'woocommerce-subscriptions' ), $product->get_sku() ) : null; + } + } + + return wcs_get_paypal_item_name( $item_desc ); + } + + /** + * Returns the string representation of this request + * + * @see SV_WC_Payment_Gateway_API_Request::to_string() + * @return string the request query string + * @since 2.0 + */ + public function to_string() { + return http_build_query( $this->get_parameters() ); + } + + /** + * Returns the string representation of this request with any and all + * sensitive elements masked or removed + * + * @see SV_WC_Payment_Gateway_API_Request::to_string_safe() + * @return string the pretty-printed request array string representation, safe for logging + * @since 2.0 + */ + public function to_string_safe() { + + $request = $this->get_parameters(); + + $sensitive_fields = array( 'USER', 'PWD', 'SIGNATURE' ); + + foreach ( $sensitive_fields as $field ) { + + if ( isset( $request[ $field ] ) ) { + + $request[ $field ] = str_repeat( '*', strlen( $request[ $field ] ) ); + } + } + + return print_r( $request, true ); + } + + /** + * Returns the request parameters after validation & filtering + * + * @throws \SV_WC_Payment_Gateway_Exception invalid amount + * @return array request parameters + * @since 2.0 + */ + public function get_parameters() { + + /** + * Filter PPE request parameters. + * + * Use this to modify the PayPal request parameters prior to validation + * + * @param array $parameters + * @param \WC_PayPal_Express_API_Request $this instance + */ + $this->parameters = apply_filters( 'wcs_paypal_request_params', $this->parameters, $this ); + + // validate parameters + foreach ( $this->parameters as $key => $value ) { + + // remove unused params + if ( '' === $value || is_null( $value ) ) { + unset( $this->parameters[ $key ] ); + } + + // format and check amounts + if ( false !== strpos( $key, 'AMT' ) ) { + + // amounts must be 10,000.00 or less for USD + if ( isset( $this->parameters['PAYMENTREQUEST_0_CURRENCYCODE'] ) && 'USD' == $this->parameters['PAYMENTREQUEST_0_CURRENCYCODE'] && $value > 10000 ) { + + throw new SV_WC_Payment_Gateway_Exception( sprintf( '%s amount of %s must be less than $10,000.00', $key, $value ) ); + } + + // PayPal requires locale-specific number formats (e.g. USD is 123.45) + // PayPal requires the decimal separator to be a period (.) + $this->parameters[ $key ] = number_format( $value, 2, '.', '' ); + } + } + + return $this->parameters; + } + + /** + * Returns the method for this request. PPE uses the API default request + * method (POST) + * + * @return null + * @since 2.0 + */ + public function get_method() { } + + /** + * Returns the request path for this request. PPE request paths do not + * vary per request + * + * @return string + * @since 2.0 + */ + public function get_path() { + return ''; + } + + + /** + * PayPal cannot properly calculate order totals when prices include tax (due + * to rounding issues), so line items are skipped and the order is sent as + * a single item + * + * @since 2.0.9 + * @param WC_Order $order Optional. The WC_Order object. Default null. + * @return bool true if line items should be skipped, false otherwise + */ + private function skip_line_items( $order = null ) { + + if ( isset( $order->prices_include_tax ) ) { + $skip_line_items = $order->prices_include_tax; + } else { + $skip_line_items = wc_prices_include_tax(); + } + + /** + * Filter whether line items should be skipped or not + * + * @since 3.3.0 + * @param bool $skip_line_items True if line items should be skipped, false otherwise + * @param WC_Order/null $order The WC_Order object or null. + */ + return apply_filters( 'wcs_paypal_reference_transaction_skip_line_items', $skip_line_items, $order ); + } + + /** + * Round a float + * + * @since 2.0.9 + * @param float $number + * @param int $precision Optional. The number of decimal digits to round to. + */ + private function round( $number, $precision = 2 ) { + return round( (float) $number, $precision ); + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-billing-agreement.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-billing-agreement.php new file mode 100644 index 0000000..5667aaf --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-billing-agreement.php @@ -0,0 +1,32 @@ +get_parameter( 'BILLINGAGREEMENTID' ); + } + +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-checkout.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-checkout.php new file mode 100644 index 0000000..a0e0fb7 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-checkout.php @@ -0,0 +1,126 @@ +get_parameter( 'TOKEN' ); + } + + /** + * Get the billing agreement status for a successful SetExpressCheckout + * + * @since 3.0.0 + * @return string|null + * @since 2.0 + */ + public function get_billing_agreement_status() { + return $this->get_parameter( 'BILLINGAGREEMENTACCEPTEDSTATUS' ); + } + + /** + * Get the shipping details from GetExpressCheckoutDetails response mapped to the WC shipping address format + * + * @since 3.0.0 + * @return array + * @since 2.0 + */ + public function get_shipping_details() { + + $details = array(); + + if ( $this->has_parameter( 'FIRSTNAME' ) ) { + + $details = array( + 'first_name' => $this->get_parameter( 'FIRSTNAME' ), + 'last_name' => $this->get_parameter( 'LASTNAME' ), + 'company' => $this->get_parameter( 'BUSINESS' ), + 'email' => $this->get_parameter( 'EMAIL' ), + 'phone' => $this->get_parameter( 'PHONENUM' ), + 'address_1' => $this->get_parameter( 'SHIPTOSTREET' ), + 'address_2' => $this->get_parameter( 'SHIPTOSTREET2' ), + 'city' => $this->get_parameter( 'SHIPTOCITY' ), + 'postcode' => $this->get_parameter( 'SHIPTOZIP' ), + 'country' => $this->get_parameter( 'SHIPTOCOUNTRYCODE' ), + 'state' => $this->get_state_code( $this->get_parameter( 'SHIPTOCOUNTRYCODE' ), $this->get_parameter( 'SHIPTOSTATE' ) ), + ); + } + + return $details; + } + + /** + * Get the note text from checkout details + * + * @return string + * @since 2.0 + */ + public function get_note_text() { + return $this->get_parameter( 'PAYMENTREQUEST_0_NOTETEXT' ); + } + + /** + * Gets the payer ID from checkout details, a payer ID is a Unique PayPal Customer Account identification number + * + * @return string + * @since 2.0 + */ + public function get_payer_id() { + return $this->get_parameter( 'PAYERID' ); + } + + /** + * Get state code given a full state name and country code + * + * @param string $country_code country code sent by PayPal + * @param string $state state name or code sent by PayPal + * @return string state code + * @since 2.0 + */ + private function get_state_code( $country_code, $state ) { + + // if not a US address, then convert state to abbreviation + if ( 'US' !== $country_code && isset( WC()->countries->states[ $country_code ] ) ) { + + $local_states = WC()->countries->states[ $country_code ]; + + if ( ! empty( $local_states ) && in_array( $state, $local_states ) ) { + + foreach ( $local_states as $key => $val ) { + + if ( $val === $state ) { + return $key; + } + } + } + } + + return $state; + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-payment.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-payment.php new file mode 100644 index 0000000..a08baeb --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-payment.php @@ -0,0 +1,358 @@ +successful_statuses = array( + self::TRANSACTION_COMPLETED, + self::TRANSACTION_PROCESSED, + self::TRANSACTION_INPROGRESS, + ); + } + + /** + * Checks if the transaction was successful + * + * @return bool true if approved, false otherwise + * @since 2.0 + */ + public function transaction_approved() { + + return in_array( $this->get_payment_status(), $this->successful_statuses ); + } + + + /** + * Returns true if the payment is pending, for instance if the payment was authorized, but not captured. There are many other + * possible reasons + * + * @link https://developer.paypal.com/docs/classic/api/merchant/DoExpressCheckoutPayment_API_Operation_NVP/#id105CAM003Y4__id116RI0UF0YK + * + * @return bool true if the transaction was held, false otherwise + * @since 2.0 + */ + public function transaction_held() { + + return self::TRANSACTION_PENDING === $this->get_payment_status(); + } + + + /** + * Gets the response status code, or null if there is no status code associated with this transaction. + * + * @link https://developer.paypal.com/docs/classic/api/merchant/DoExpressCheckoutPayment_API_Operation_NVP/#id105CAM003Y4__id116RI0UF0YK + * + * @return string status code + * @since 2.0 + */ + public function get_status_code() { + + return $this->get_payment_status(); + } + + + /** + * Gets the response status message, or null if there is no status message associated with this transaction. + * + * PayPal provides additional info only for Pending or Completed-Funds-Held transactions. + * + * @return string status message + * @since 2.0 + */ + public function get_status_message() { + + $message = ''; + + if ( $this->transaction_held() ) { + + // PayPal's "pending" is our Held + $message = $this->get_pending_reason(); + + } elseif ( 'echeck' == $this->get_payment_type() ) { + + // add some additional info for eCheck payments + // translators: placeholder is localised datetime + $message = sprintf( __( 'expected clearing date %s', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), strtotime( $this->get_payment_parameter( 'EXPECTEDECHECKCLEARDATE' ) ) ) ); + } + + // add fraud filters + if ( $filters = $this->get_fraud_filters() ) { + + foreach ( $filters as $filter ) { + $message .= sprintf( ' %s: %s', $filter['name'], $filter['id'] ); + } + } + + return $message; + } + + + /** + * Gets the response transaction id, or null if there is no transaction id associated with this transaction. + * + * @return string transaction id + * @since 2.0 + */ + public function get_transaction_id() { + + return $this->get_payment_parameter( 'TRANSACTIONID' ); + } + + + /** + * Return true if the response has a payment type other than `none` + * + * @return bool + * @since 2.0 + */ + public function has_payment_type() { + + return 'none' !== $this->get_payment_type(); + } + + + /** + * Get the PayPal payment type, either `none`, `echeck`, or `instant` + * + * @since 2.0.9 + * @return string + */ + public function get_payment_type() { + + return $this->get_payment_parameter( 'PAYMENTTYPE' ); + } + + + /** + * Gets payment status + * + * @return string + * @since 2.0 + */ + private function get_payment_status() { + + return $this->has_payment_parameter( 'PAYMENTSTATUS' ) ? $this->get_payment_parameter( 'PAYMENTSTATUS' ) : 'N/A'; + } + + + /** + * Gets the pending reason + * + * @return string + * @since 2.0 + */ + private function get_pending_reason() { + + return $this->has_payment_parameter( 'PENDINGREASON' ) ? $this->get_payment_parameter( 'PENDINGREASON' ) : 'N/A'; + } + + + /** AVS/CSC Methods *******************************************************/ + + + /** + * PayPal Express does not return an authorization code + * + * @return string credit card authorization code + * @since 2.0 + */ + public function get_authorization_code() { + return false; + } + + + /** + * Returns the result of the AVS check + * + * @return string result of the AVS check, if any + * @since 2.0 + */ + public function get_avs_result() { + + if ( $filters = $this->get_fraud_filters() ) { + + foreach ( $filters as $filter ) { + + if ( in_array( $filter['id'], range( 1, 3 ) ) ) { + + return $filter['id']; + } + } + } + + return null; + } + + + /** + * Returns the result of the CSC check + * + * @return string result of CSC check + * @since 2.0 + */ + public function get_csc_result() { + + if ( $filters = $this->get_fraud_filters() ) { + + foreach ( $filters as $filter ) { + + if ( '4' == $filter['id'] ) { + + return $filter['id']; + } + } + } + + return null; + } + + + /** + * Returns true if the CSC check was successful + * + * @return boolean true if the CSC check was successful + * @since 2.0 + */ + public function csc_match() { + + return is_null( $this->get_csc_result() ); + } + + + /** + * Return any fraud management data available. This data is explicitly + * enabled in the request, but PayPal recommends checking certain error + * conditions prior to accessing this data. + * + * This data provides additional context for why a transaction was held for + * review or declined. + * + * @link https://developer.paypal.com/webapps/developer/docs/classic/fmf/integration-guide/FMFProgramming/#id091UNG0065Z + * @link https://developer.paypal.com/webapps/developer/docs/classic/api/merchant/DoReferenceTransaction_API_Operation_NVP/#id09BUI01L0K3__id0861GA0N07U (L_FMFfilterIDn Type Fields) + * + * @return array $filters { + * @type string $id filter ID, integer from 1-17 + * @type string name filter name, short description for filter + * } + * @since 2.0 + */ + private function get_fraud_filters() { + + $filters = array(); + + if ( '11610' == $this->get_api_error_code() ) { + + $type = 'PENDING'; + + } elseif ( '11611' == $this->get_api_error_code() ) { + + $type = 'DENY'; + + } else { + + // not supporting REPORT type yet + return $filters; + } + + foreach ( range( 0, 9 ) as $index ) { + + if ( $this->has_parameter( "L_FMF{$type}ID{$index}" ) && $this->has_parameter( "L_FMF{$type}NAME{$index}" ) ) { + $filters[] = array( + 'id' => $this->get_parameter( "L_FMF{$type}ID{$index}" ), + 'name' => $this->get_parameter( "L_FMF{$type}NAME{$index}" ), + ); + } + } + + return $filters; + } + + + /** + * Check if the response has a specific payment parameter. + * + * A wrapper around @see WCS_PayPal_Reference_Transaction_API_Response::has_parameter() + * that prepends the @see self::get_payment_parameter_prefix(). + * + * @since 2.0.9 + * @param string $name parameter name + * @return bool + */ + protected function has_payment_parameter( $name ) { + return $this->has_parameter( $this->get_payment_parameter_prefix() . $name ); + } + + + /** + * Gets a given payment parameter's value, or null if parameter is not set or empty. + * + * A wrapper around @see WCS_PayPal_Reference_Transaction_API_Response::get_parameter() + * that prepends the @see self::get_payment_parameter_prefix(). + * + * @since 2.0.9 + * @param string $name parameter name + * @return string|null + */ + protected function get_payment_parameter( $name ) { + return $this->get_parameter( $this->get_payment_parameter_prefix() . $name ); + } + + + /** + * DoExpressCheckoutPayment API responses have a prefix for the payment + * parameters. Parallels payments are not used, so the numeric portion of + * the prefix is always '0' + * + * @since 2.0.9 + * @return string + */ + protected function get_payment_parameter_prefix() { + return 'PAYMENTINFO_0_'; + } + +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-recurring-payment.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-recurring-payment.php new file mode 100644 index 0000000..319a8bb --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-recurring-payment.php @@ -0,0 +1,50 @@ +parameters ); + } + + /** + * Checks if response contains an API error code + * + * @link https://developer.paypal.com/docs/classic/api/errorcodes/ + * + * @return bool true if has API error, false otherwise + * @since 2.0 + */ + public function has_api_error() { + + // assume something went wrong if ACK is missing + if ( ! $this->has_parameter( 'ACK' ) ) { + return true; + } + + // any non-success ACK is considered an error, see + // https://developer.paypal.com/docs/classic/api/NVPAPIOverview/#id09C2F0K30L7 + return ( 'Success' !== $this->get_parameter( 'ACK' ) && 'SuccessWithWarning' !== $this->get_parameter( 'ACK' ) ); + } + + /** + * Gets the API error code + * + * Note that PayPal can return multiple error codes, which are merged here + * for convenience + * + * @link https://developer.paypal.com/docs/classic/api/errorcodes/ + * + * @return string + * @since 2.0 + */ + public function get_api_error_code() { + + $error_codes = array(); + + foreach ( range( 0, 9 ) as $index ) { + + if ( $this->has_parameter( "L_ERRORCODE{$index}" ) ) { + $error_codes[] = $this->get_parameter( "L_ERRORCODE{$index}" ); + } + } + + return empty( $error_codes ) ? 'N/A' : trim( implode( ', ', $error_codes ) ); + } + + /** + * Gets the API error message + * + * Note that PayPal can return multiple error messages, which are merged here + * for convenience + * + * @link https://developer.paypal.com/docs/classic/api/errorcodes/ + * + * @return string + * @since 2.0 + */ + public function get_api_error_message() { + + $error_messages = array(); + + foreach ( range( 0, 9 ) as $index ) { + + if ( $this->has_parameter( "L_SHORTMESSAGE{$index}" ) ) { + + $error_message = sprintf( '%s: %s - %s', + $this->has_parameter( "L_SEVERITYCODE{$index}" ) ? $this->get_parameter( "L_SEVERITYCODE{$index}" ) : _x( 'Error', 'used in api error message if there is no severity code from PayPal', 'woocommerce-subscriptions' ), + $this->get_parameter( "L_SHORTMESSAGE{$index}" ), + $this->has_parameter( "L_LONGMESSAGE{$index}" ) ? $this->get_parameter( "L_LONGMESSAGE{$index}" ) : _x( 'Unknown error', 'used in api error message if there is no long message', 'woocommerce-subscriptions' ) + ); + + // append additional info if available + if ( $this->has_parameter( "L_ERRORPARAMID{$index}" ) && $this->has_parameter( "L_ERRORPARAMVALUE{$index}" ) ) { + $error_message .= sprintf( ' (%s - %s)', $this->get_parameter( "L_ERRORPARAMID{$index}" ), $this->get_parameter( "L_ERRORPARAMVALUE{$index}" ) ); + } + + $error_messages[] = $error_message; + } + } + + return empty( $error_messages ) ? _x( 'N/A', 'no information about something', 'woocommerce-subscriptions' ) : trim( implode( ', ', $error_messages ) ); + } + + /** + * Returns true if the parameter is not empty + * + * @param string $name parameter name + * @return bool + * @since 2.0 + */ + protected function has_parameter( $name ) { + return ! empty( $this->parameters[ $name ] ); + } + + /** + * Gets the parameter value, or null if parameter is not set or empty + * + * @param string $name parameter name + * @return string|null + * @since 2.0 + */ + protected function get_parameter( $name ) { + return $this->has_parameter( $name ) ? $this->parameters[ $name ] : null; + } + + /** + * Returns a message appropriate for a frontend user. This should be used + * to provide enough information to a user to allow them to resolve an + * issue on their own, but not enough to help nefarious folks fishing for + * info. + * + * @link https://developer.paypal.com/docs/classic/api/errorcodes/ + * + * @return string user message, if there is one + * @since 2.0 + */ + public function get_user_message() { + + $allowed_user_error_message_codes = array( + '10445', + '10474', + '12126', + '13113', + '13122', + '13112', + ); + + return in_array( $this->get_api_error_code(), $allowed_user_error_message_codes ) ? $this->get_api_error_message() : null; + } + + /** + * Returns the string representation of this response + * + * @return string response + * @since 2.0 + */ + public function to_string() { + + return print_r( $this->parameters, true ); + } + + /** + * Returns the string representation of this response with any and all + * sensitive elements masked or removed + * + * @return string response safe for logging/displaying + * @since 2.0 + */ + public function to_string_safe() { + + // no sensitive data to mask + return $this->to_string(); + } + + /** + * Get the order for a request based on the 'custom' response field + * + * @see WC_Gateway_Paypal_Response::get_paypal_order() + * @param string $response the raw URL-encoded response string + * @since 2.0 + */ + public function get_order() { + + // assume something went wrong if ACK is missing + if ( $this->has_parameter( 'CUSTOM' ) ) { + return $this->get_paypal_order( $this->get_parameter( 'CUSTOM' ) ); + } + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api.php new file mode 100644 index 0000000..b176b01 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api.php @@ -0,0 +1,299 @@ +gateway_id = $gateway_id; + + // request URI does not vary per-request + $this->request_uri = ( 'production' === $api_environment ) ? self::PRODUCTION_ENDPOINT : self::SANDBOX_ENDPOINT; + + // PayPal requires HTTP 1.1 + $this->request_http_version = '1.1'; + + $this->api_username = $api_username; + $this->api_password = $api_password; + $this->api_signature = $api_signature; + } + + /** + * Get PayPal URL parameters for the checkout URL + * + * @param array $paypal_args + * @param WC_Order $order + * @return array + * @since 2.0 + */ + public function get_paypal_args( $paypal_args, $order ) { + + $request = $this->get_new_request(); + + // First we need to request an express checkout token for setting up a billing agreement, to do that, we need to pull details of the transaction from the PayPal Standard args and massage them into the Express Checkout params + $response = $this->set_express_checkout( array( + 'currency' => $paypal_args['currency_code'], + 'return_url' => $this->get_callback_url( 'create_billing_agreement' ), + 'cancel_url' => $paypal_args['cancel_return'], + 'notify_url' => $paypal_args['notify_url'], + 'custom' => $paypal_args['custom'], + 'order' => $order, + ) ); + + $paypal_args = array( + 'cmd' => '_express-checkout', + 'token' => $response->get_token(), + ); + + return $paypal_args; + } + + /** + * Check account for reference transaction support + * + * For reference transactions to be enabled, we need to be able to setup a dummy SetExpressCheckout request without receiving any APIs errors. + * This ensures there are no API credentials errors (e.g. error code 10008: "Security header is not valid") as well as testing the account for + * reference transaction support. If the account does not have reference transaction support enabled, PayPal will return the error code + * error code 11452: "Merchant not enabled for reference transactions". + * + * @link https://developer.paypal.com/docs/classic/api/errorcodes/#id09C3G0PJ0N9__id5e8c50e9-4f1b-462a-8586-399b63b07f1a + * + * @return bool + * @since 2.0 + */ + public function are_reference_transactions_enabled() { + + $request = $this->get_new_request(); + + $request->set_express_checkout( array( + // As we are only testing for whether billing agreements are allowed, we don't need to se any other details + 'currency' => get_woocommerce_currency(), + 'return_url' => $this->get_callback_url( 'reference_transaction_account_check' ), + 'cancel_url' => $this->get_callback_url( 'reference_transaction_account_check' ), + 'notify_url' => $this->get_callback_url( 'reference_transaction_account_check' ), + ) ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response' ); + + try { + $response = $this->perform_request( $request ); + + if ( ! $response->has_api_error() ) { + $reference_transactions_enabled = true; + } else { + $reference_transactions_enabled = false; + } + } catch ( Exception $e ) { + $reference_transactions_enabled = false; + } + + return $reference_transactions_enabled; + } + + /** + * Set Express Checkout + * + * @param array $args @see WCS_PayPal_Reference_Transaction_API_Request::set_express_checkout() for details + * @throws Exception network timeouts, etc + * @return WCS_PayPal_Reference_Transaction_API_Response_Checkout response object + * @since 2.0 + */ + public function set_express_checkout( $args ) { + + $request = $this->get_new_request(); + + $request->set_express_checkout( $args ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response_Checkout' ); + + return $this->perform_request( $request ); + } + + /** + * Create a billing agreement, required when a subscription sign-up has no initial payment + * + * @link https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECReferenceTxns/#id094TB0Y0J5Z__id094TB4003HS + * @link https://developer.paypal.com/docs/classic/api/merchant/CreateBillingAgreement_API_Operation_NVP/ + * + * @param string $token token from SetExpressCheckout response + * @return WCS_PayPal_Reference_Transaction_API_Response_Billing_Agreement response object + * @since 2.0 + */ + public function create_billing_agreement( $token ) { + + $request = $this->get_new_request(); + + $request->create_billing_agreement( $token ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response_Billing_Agreement' ); + + return $this->perform_request( $request ); + } + + /** + * Get Express Checkout Details + * + * @param string $token Token from set_express_checkout response + * @return WC_PayPal_Reference_Transaction_API_Checkout_Response response object + * @throws Exception network timeouts, etc + * @since 2.0 + */ + public function get_express_checkout_details( $token ) { + + $request = $this->get_new_request(); + + $request->get_express_checkout_details( $token ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response_Checkout' ); + + return $this->perform_request( $request ); + } + + /** + * Process an express checkout payment and billing agreement creation + * + * @param string $token PayPal Express Checkout token returned by SetExpressCheckout operation + * @param WC_Order $order order object + * @param array $args + * @return WCS_PayPal_Reference_Transaction_API_Response_Payment refund response + * @since 2.0.9 + */ + public function do_express_checkout( $token, $order, $args ) { + + $this->order = $order; + + $request = $this->get_new_request(); + + $request->do_express_checkout( $token, $order, $args ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response_Payment' ); + + return $this->perform_request( $request ); + } + + /** + * Perform a reference transaction for the given order + * + * @see SV_WC_Payment_Gateway_API::refund() + * @param WC_Order $order order object + * @return SV_WC_Payment_Gateway_API_Response refund response + * @throws SV_WC_Payment_Gateway_Exception network timeouts, etc + * @since 2.0 + */ + public function do_reference_transaction( $reference_id, $order, $args ) { + + $this->order = $order; + + $request = $this->get_new_request(); + + $request->do_reference_transaction( $reference_id, $order, $args ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response_Recurring_Payment' ); + + return $this->perform_request( $request ); + } + + /** + * Change the status of a subscription for a given order/profile ID + * + * @since 2.0.0 + * @see SV_WC_Payment_Gateway_API::refund() + * @param WC_Order $order order object + * @return SV_WC_Payment_Gateway_API_Response refund response + * @throws SV_WC_Payment_Gateway_Exception network timeouts, etc + */ + public function manage_recurring_payments_profile_status( $profile_id, $new_status, $order ) { + + $this->order = $order; + + $request = $this->get_new_request(); + + $request->manage_recurring_payments_profile_status( $profile_id, $new_status, $order ); + + $this->set_response_handler( 'WCS_PayPal_Reference_Transaction_API_Response_Checkout' ); + + return $this->perform_request( $request ); + } + + /** Helper methods ******************************************************/ + + /** + * Get the wc-api URL to redirect to + * + * @param string $action checkout action, either `set_express_checkout or `get_express_checkout_details` + * @return string URL + * @since 2.0 + */ + public function get_callback_url( $action ) { + return add_query_arg( 'action', $action, WC()->api_request_url( 'wcs_paypal' ) ); + } + + /** + * Builds and returns a new API request object + * + * @see \WCS_SV_API_Base::get_new_request() + * @param array $args + * @return WC_PayPal_Reference_Transaction_API_Request API request object + * @since 2.0 + */ + protected function get_new_request( $args = array() ) { + return new WCS_PayPal_Reference_Transaction_API_Request( $this->api_username, $this->api_password, $this->api_signature, self::VERSION ); + } + + /** + * Supposed to return the main gatewya plugin class, but we don't have one of those + * + * @see \WCS_SV_API_Base::get_plugin() + * @return object + * @since 2.0 + */ + protected function get_plugin() { + return WCS_PayPal::instance(); + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-ipn-handler.php b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-ipn-handler.php new file mode 100644 index 0000000..59d917e --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-ipn-handler.php @@ -0,0 +1,130 @@ +receiver_email = $receiver_email; + $this->sandbox = $sandbox; + } + + /** + * There was a valid response + * + * Based on the IPN Variables documented here: https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNandPDTVariables/#id091EB0901HT + * + * @param array $posted Post data after wp_unslash + */ + public function valid_response( $transaction_details ) { + + if ( ! $this->validate_transaction_type( $transaction_details['txn_type'] ) ) { + return; + } + + switch ( $transaction_details['txn_type'] ) { + + case 'mp_cancel': + $this->cancel_subscriptions( $transaction_details['mp_id'] ); + break; + + case 'merch_pmt' : + + if ( ! empty( $transaction_details['custom'] ) && ( $order = $this->get_paypal_order( $transaction_details['custom'] ) ) ) { + + $transaction_details['payment_status'] = strtolower( $transaction_details['payment_status'] ); + + // Sandbox fix + if ( isset( $transaction_details['test_ipn'] ) && 1 == $transaction_details['test_ipn'] && 'pending' == $transaction_details['payment_status'] ) { + $transaction_details['payment_status'] = 'completed'; + } + + WC_Gateway_Paypal::log( 'Found order #' . $order->id ); + WC_Gateway_Paypal::log( 'Payment status: ' . $transaction_details['payment_status'] ); + + if ( method_exists( $this, 'payment_status_' . $transaction_details['payment_status'] ) ) { + call_user_func( array( $this, 'payment_status_' . $transaction_details['payment_status'] ), $order, $transaction_details ); + } else { + WC_Gateway_Paypal::log( 'Unknown payment status: ' . $transaction_details['payment_status'] ); + } + } + break; + + case 'mp_signup' : + // Silence is Golden + break; + + } + exit; + } + + /** + * Find all subscription with a given billing agreement ID and cancel them becasue that billing agreement has been + * cancelled at PayPal, and therefore, no future payments can be charged. + * + * @since 2.0 + */ + protected function cancel_subscriptions( $billing_agreement_id ) { + + $subscription_ids = get_posts( array( + 'posts_per_page' => -1, + 'post_type' => 'shop_subscription', + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => '_paypal_subscription_id', + 'compare' => '=', + 'value' => $billing_agreement_id, + ), + ), + ) ); + + if ( empty( $subscription_ids ) ) { + return; + } + + $note = esc_html__( 'Billing agreement cancelled at PayPal.', 'woocommerce-subscriptions' ); + + foreach ( $subscription_ids as $subscription_id ) { + + $subscription = wcs_get_subscription( $subscription_id ); + + try { + if ( false !== $subscription && ! $subscription->has_status( wcs_get_subscription_ended_statuses() ) ) { + $subscription->cancel_order( $note ); + WC_Gateway_Paypal::log( sprintf( 'Subscription %s Cancelled: %s', $subscription_id, $note ) ); + } + } catch ( Exception $e ) { + WC_Gateway_Paypal::log( sprintf( 'Unable to cancel subscription %s: %s', $subscription_id, $e->getMessage() ) ); + } + } + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-standard-change-payment-method.php b/includes/gateways/paypal/includes/class-wcs-paypal-standard-change-payment-method.php new file mode 100644 index 0000000..d301460 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-standard-change-payment-method.php @@ -0,0 +1,96 @@ +id ) && 'paypal' == $gateway->id && ! empty( $gateway->order_button_text ) ) { + $change_button_text = $gateway->order_button_text; + } + + return $change_button_text; + } + +} +WCS_PayPal_Standard_Change_Payment_Method::init(); diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php b/includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php new file mode 100644 index 0000000..510e232 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php @@ -0,0 +1,657 @@ +receiver_email = $receiver_email; + $this->sandbox = $sandbox; + } + + /** + * There was a valid response + * + * Based on the IPN Variables documented here: https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNandPDTVariables/#id091EB0901HT + * + * @param array $transaction_details Post data after wp_unslash + * @since 2.0 + */ + public function valid_response( $transaction_details ) { + global $wpdb; + + $transaction_details = stripslashes_deep( $transaction_details ); + + if ( ! $this->validate_transaction_type( $transaction_details['txn_type'] ) ) { + return; + } + + $transaction_details['txn_type'] = strtolower( $transaction_details['txn_type'] ); + + $this->process_ipn_request( $transaction_details ); + + } + + /** + * Process a PayPal Standard Subscription IPN request + * + * @param array $transaction_details Post data after wp_unslash + * @since 2.0 + */ + protected function process_ipn_request( $transaction_details ) { + + // Get the subscription ID and order_key with backward compatibility + $subscription_id_and_key = self::get_order_id_and_key( $transaction_details, 'shop_subscription' ); + $subscription = wcs_get_subscription( $subscription_id_and_key['order_id'] ); + $subscription_key = $subscription_id_and_key['order_key']; + + // We have an invalid $subscription, probably because invoice_prefix has changed since the subscription was first created, so get the subscription by order key + if ( ! isset( $subscription->id ) ) { + $subscription = wcs_get_subscription( wc_get_order_id_by_order_key( $subscription_key ) ); + } + + if ( 'recurring_payment_suspended_due_to_max_failed_payment' == $transaction_details['txn_type'] && empty( $subscription ) ) { + WC_Gateway_Paypal::log( 'Returning as "recurring_payment_suspended_due_to_max_failed_payment" transaction is for a subscription created with Express Checkout' ); + return; + } + + if ( empty( $subscription ) ) { + WC_Gateway_Paypal::log( 'Subscription IPN Error: Could not find matching Subscription.' ); + exit; + } + + if ( $subscription->order_key != $subscription_key ) { + WC_Gateway_Paypal::log( 'Subscription IPN Error: Subscription Key does not match invoice.' ); + exit; + } + + if ( isset( $transaction_details['ipn_track_id'] ) ) { + + // Make sure the IPN request has not already been handled + $handled_ipn_requests = get_post_meta( $subscription->id, '_paypal_ipn_tracking_ids', true ); + + if ( empty( $handled_ipn_requests ) ) { + $handled_ipn_requests = array(); + } + + // The 'ipn_track_id' is not a unique ID and is shared between different transaction types, so create a unique ID by prepending the transaction type + $ipn_id = $transaction_details['txn_type'] . '_' . $transaction_details['ipn_track_id']; + + if ( in_array( $ipn_id, $handled_ipn_requests ) ) { + WC_Gateway_Paypal::log( 'Subscription IPN Error: IPN ' . $ipn_id . ' message has already been correctly handled.' ); + exit; + } + + // Make sure we're not in the process of handling this IPN request on a server under extreme load and therefore, taking more than a minute to process it (which is the amount of time PayPal allows before resending the IPN request) + $ipn_lock_transient_name = 'wcs_pp_' . $ipn_id; // transient names need to be less than 45 characters and the $ipn_id will be around 30 characters, e.g. subscr_payment_5ab4c38e1f39d + + if ( 'in-progress' == get_transient( $ipn_lock_transient_name ) && 'recurring_payment_suspended_due_to_max_failed_payment' !== $transaction_details['txn_type'] ) { + + WC_Gateway_Paypal::log( 'Subscription IPN Error: an older IPN request with ID ' . $ipn_id . ' is still in progress.' ); + + // We need to send an error code to make sure PayPal does retry the IPN after our lock expires, in case something is actually going wrong and the server isn't just taking a long time to process the request + status_header( 503 ); + exit; + } + + // Set a transient to block IPNs with this transaction ID for the next 5 minutes + set_transient( $ipn_lock_transient_name, 'in-progress', apply_filters( 'woocommerce_subscriptions_paypal_ipn_request_lock_time', 5 * MINUTE_IN_SECONDS ) ); + + } + + if ( isset( $transaction_details['txn_id'] ) ) { + + // Make sure the IPN request has not already been handled + $handled_transactions = get_post_meta( $subscription->id, '_paypal_transaction_ids', true ); + + if ( empty( $handled_transactions ) ) { + $handled_transactions = array(); + } + + $transaction_id = $transaction_details['txn_id']; + + if ( isset( $transaction_details['txn_type'] ) ) { + $transaction_id .= '_' . $transaction_details['txn_type']; + } + + // The same transaction ID is used for different payment statuses, so make sure we handle it only once. See: http://stackoverflow.com/questions/9240235/paypal-ipn-unique-identifier + if ( isset( $transaction_details['payment_status'] ) ) { + $transaction_id .= '_' . $transaction_details['payment_status']; + } + + if ( in_array( $transaction_id, $handled_transactions ) ) { + WC_Gateway_Paypal::log( 'Subscription IPN Error: transaction ' . $transaction_id . ' has already been correctly handled.' ); + exit; + } + } + + WC_Gateway_Paypal::log( 'Subscription transaction details: ' . print_r( $transaction_details, true ) ); + WC_Gateway_Paypal::log( 'Subscription Transaction Type: ' . $transaction_details['txn_type'] ); + + $is_renewal_sign_up_after_failure = false; + + // If the invoice ID doesn't match the default invoice ID and contains the string '-wcsfrp-', the IPN is for a subscription payment to fix up a failed payment + if ( in_array( $transaction_details['txn_type'], array( 'subscr_signup', 'subscr_payment' ) ) && false !== strpos( $transaction_details['invoice'], '-wcsfrp-' ) ) { + + $renewal_order = wc_get_order( substr( $transaction_details['invoice'], strrpos( $transaction_details['invoice'], '-' ) + 1 ) ); + + // check if the failed signup has been previously recorded + if ( $renewal_order->id != get_post_meta( $subscription->id, '_paypal_failed_sign_up_recorded', true ) ) { + $is_renewal_sign_up_after_failure = true; + } + } + + // If the invoice ID doesn't match the default invoice ID and contains the string '-wcscpm-', the IPN is for a subscription payment method change + if ( 'subscr_signup' == $transaction_details['txn_type'] && false !== strpos( $transaction_details['invoice'], '-wcscpm-' ) ) { + $is_payment_change = true; + } else { + $is_payment_change = false; + } + + // Ignore IPN messages when the payment method isn't PayPal + if ( 'paypal' != $subscription->payment_method ) { + + // The 'recurring_payment_suspended' transaction is actually an Express Checkout transaction type, but PayPal also send it for PayPal Standard Subscriptions suspended by admins at PayPal, so we need to handle it *if* the subscription has PayPal as the payment method, or leave it if the subscription is using a different payment method (because it might be using PayPal Express Checkout or PayPal Digital Goods) + if ( 'recurring_payment_suspended' == $transaction_details['txn_type'] ) { + + WC_Gateway_Paypal::log( '"recurring_payment_suspended" IPN ignored: recurring payment method is not "PayPal". Returning to allow another extension to process the IPN, like PayPal Digital Goods.' ); + return; + + } elseif ( false === $is_renewal_sign_up_after_failure && false === $is_payment_change ) { + + WC_Gateway_Paypal::log( 'IPN ignored, recurring payment method has changed.' ); + exit; + + } + } + + if ( $is_renewal_sign_up_after_failure || $is_payment_change ) { + + // Store the old profile ID on the order (for the first IPN message that comes through) + $existing_profile_id = wcs_get_paypal_id( $subscription ); + + if ( empty( $existing_profile_id ) || $existing_profile_id !== $transaction_details['subscr_id'] ) { + update_post_meta( $subscription->id, '_old_paypal_subscriber_id', $existing_profile_id ); + update_post_meta( $subscription->id, '_old_payment_method', $subscription->payment_method ); + } + } + + // Save the profile ID if it's not a cancellation/expiration request + if ( isset( $transaction_details['subscr_id'] ) && ! in_array( $transaction_details['txn_type'], array( 'subscr_cancel', 'subscr_eot' ) ) ) { + wcs_set_paypal_id( $subscription, $transaction_details['subscr_id'] ); + + if ( wcs_is_paypal_profile_a( $transaction_details['subscr_id'], 'out_of_date_id' ) && 'disabled' != get_option( 'wcs_paypal_invalid_profile_id' ) ) { + update_option( 'wcs_paypal_invalid_profile_id', 'yes' ); + } + } + + $is_first_payment = ( $subscription->get_completed_payment_count() < 1 ) ? true : false; + + if ( $subscription->has_status( 'switched' ) ) { + WC_Gateway_Paypal::log( 'IPN ignored, subscription has been switched.' ); + exit; + } + + switch ( $transaction_details['txn_type'] ) { + case 'subscr_signup': + + // Store PayPal Details on Subscription and Order + $this->save_paypal_meta_data( $subscription, $transaction_details ); + $this->save_paypal_meta_data( $subscription->order, $transaction_details ); + + // When there is a free trial & no initial payment amount, we need to mark the order as paid and activate the subscription + if ( ! $is_payment_change && ! $is_renewal_sign_up_after_failure && 0 == $subscription->order->get_total() ) { + // Safe to assume the subscription has an order here because otherwise we wouldn't get a 'subscr_signup' IPN + $subscription->order->payment_complete(); // No 'txn_id' value for 'subscr_signup' IPN messages + update_post_meta( $subscription->id, '_paypal_first_ipn_ignored_for_pdt', 'true' ); + } + + // Payment completed + if ( $is_payment_change ) { + + // Set PayPal as the new payment method + WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $subscription, 'paypal' ); + + // We need to cancel the subscription now that the method has been changed successfully + if ( 'paypal' == get_post_meta( $subscription->id, '_old_payment_method', true ) ) { + self::cancel_subscription( $subscription, get_post_meta( $subscription->id, '_old_paypal_subscriber_id', true ) ); + } + + $subscription->add_order_note( _x( 'IPN subscription payment method changed to PayPal.', 'when it is a payment change, and there is a subscr_signup message, this will be a confirmation message that PayPal accepted it being the new payment method', 'woocommerce-subscriptions' ) ); + + } else { + + $subscription->add_order_note( __( 'IPN subscription sign up completed.', 'woocommerce-subscriptions' ) ); + + } + + if ( $is_payment_change ) { + WC_Gateway_Paypal::log( 'IPN subscription payment method changed for subscription ' . $subscription->id ); + } else { + WC_Gateway_Paypal::log( 'IPN subscription sign up completed for subscription ' . $subscription->id ); + } + + break; + + case 'subscr_payment': + + if ( 0.01 == $transaction_details['mc_gross'] && 1 == $subscription->get_completed_payment_count() ) { + WC_Gateway_Paypal::log( 'IPN ignored, treating IPN as secondary trial period.' ); + exit; + } + + if ( ! $is_first_payment && ! $is_renewal_sign_up_after_failure ) { + + if ( $subscription->has_status( 'active' ) ) { + remove_action( 'woocommerce_subscription_on-hold_paypal', 'WCS_PayPal_Status_Manager::suspend_subscription' ); + $subscription->update_status( 'on-hold' ); + add_action( 'woocommerce_subscription_on-hold_paypal', 'WCS_PayPal_Status_Manager::suspend_subscription' ); + } + + // Generate a renewal order to record the payment (and determine how much is due) + $renewal_order = wcs_create_renewal_order( $subscription ); + + // Set PayPal as the payment method (we can't use $renewal_order->set_payment_method() here as it requires an object we don't have) + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + $renewal_order->set_payment_method( $available_gateways['paypal'] ); + } + + if ( 'completed' == strtolower( $transaction_details['payment_status'] ) ) { + // Store PayPal Details + $this->save_paypal_meta_data( $subscription, $transaction_details ); + + // Subscription Payment completed + $subscription->add_order_note( __( 'IPN subscription payment completed.', 'woocommerce-subscriptions' ) ); + + WC_Gateway_Paypal::log( 'IPN subscription payment completed for subscription ' . $subscription->id ); + + // First payment on order, process payment & activate subscription + if ( $is_first_payment ) { + + $subscription->order->payment_complete( $transaction_details['txn_id'] ); + + // Store PayPal Details on Order + $this->save_paypal_meta_data( $subscription->order, $transaction_details ); + + // IPN got here first or PDT will never arrive. Normally PDT would have arrived, so the first IPN would not be the first payment. In case the the first payment is an IPN, we need to make sure to not ignore the second one + update_post_meta( $subscription->id, '_paypal_first_ipn_ignored_for_pdt', 'true' ); + + // Ignore the first IPN message if the PDT should have handled it (if it didn't handle it, it will have been dealt with as first payment), but set a flag to make sure we only ignore it once + } elseif ( $subscription->get_completed_payment_count() == 1 && '' !== WCS_PayPal::get_option( 'identity_token' ) && 'true' != get_post_meta( $subscription->id, '_paypal_first_ipn_ignored_for_pdt', true ) && false === $is_renewal_sign_up_after_failure ) { + + WC_Gateway_Paypal::log( 'IPN subscription payment ignored for subscription ' . $subscription->id . ' due to PDT previously handling the payment.' ); + + update_post_meta( $subscription->id, '_paypal_first_ipn_ignored_for_pdt', 'true' ); + + // Process the payment if the subscription is active + } elseif ( ! $subscription->has_status( array( 'cancelled', 'expired', 'switched', 'trash' ) ) ) { + + if ( true === $is_renewal_sign_up_after_failure && is_object( $renewal_order ) ) { + + update_post_meta( $subscription->id, '_paypal_failed_sign_up_recorded', $renewal_order->id ); + + // We need to cancel the old subscription now that the method has been changed successfully + if ( 'paypal' == get_post_meta( $subscription->id, '_old_payment_method', true ) ) { + + $profile_id = get_post_meta( $subscription->id, '_old_paypal_subscriber_id', true ); + + // Make sure we don't cancel the current profile + if ( $profile_id !== $transaction_details['subscr_id'] ) { + self::cancel_subscription( $subscription, $profile_id ); + } + + $subscription->add_order_note( __( 'IPN subscription failing payment method changed.', 'woocommerce-subscriptions' ) ); + } + } + + try { + + // to cover the case when PayPal drank too much coffee and sent IPNs early - needs to happen before $renewal_order->payment_complete + $update_dates = array(); + + if ( $subscription->get_time( 'trial_end' ) > gmdate( 'U' ) ) { + $update_dates['trial_end'] = gmdate( 'Y-m-d H:i:s', gmdate( 'U' ) - 1 ); + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment for subscription %d: trial_end is in futute (date: %s) setting to %s.', $subscription->id, $subscription->get_date( 'trial_end' ), $update_dates['trial_end'] ) ); + } else { + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment for subscription %d: trial_end is in past (date: %s).', $subscription->id, $subscription->get_date( 'trial_end' ) ) ); + } + + if ( $subscription->get_time( 'next_payment' ) > gmdate( 'U' ) ) { + $update_dates['next_payment'] = gmdate( 'Y-m-d H:i:s', gmdate( 'U' ) - 1 ); + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment for subscription %d: next_payment is in future (date: %s) setting to %s.', $subscription->id, $subscription->get_date( 'next_payment' ), $update_dates['next_payment'] ) ); + } else { + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment for subscription %d: next_payment is in past (date: %s).', $subscription->id, $subscription->get_date( 'next_payment' ) ) ); + } + + if ( ! empty( $update_dates ) ) { + $subscription->update_dates( $update_dates ); + } + } catch ( Exception $e ) { + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment exception subscription %d: %s.', $subscription->id, $e->getMessage() ) ); + } + + remove_action( 'woocommerce_subscription_activated_paypal', 'WCS_PayPal_Status_Manager::reactivate_subscription' ); + + try { + $renewal_order->payment_complete( $transaction_details['txn_id'] ); + } catch ( Exception $e ) { + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment exception calling $renewal_order->payment_complete() for subscription %d: %s.', $subscription->id, $e->getMessage() ) ); + } + + $renewal_order->add_order_note( __( 'IPN subscription payment completed.', 'woocommerce-subscriptions' ) ); + + add_action( 'woocommerce_subscription_activated_paypal', 'WCS_PayPal_Status_Manager::reactivate_subscription' ); + + wcs_set_paypal_id( $renewal_order, $transaction_details['subscr_id'] ); + } + } elseif ( in_array( strtolower( $transaction_details['payment_status'] ), array( 'pending', 'failed' ) ) ) { + + // Subscription Payment completed + // translators: placeholder is payment status (e.g. "completed") + $subscription->add_order_note( sprintf( _x( 'IPN subscription payment %s.', 'used in order note', 'woocommerce-subscriptions' ), $transaction_details['payment_status'] ) ); + + if ( ! $is_first_payment ) { + + update_post_meta( $renewal_order->id, '_transaction_id', $transaction_details['txn_id'] ); + + // translators: placeholder is payment status (e.g. "completed") + $renewal_order->add_order_note( sprintf( _x( 'IPN subscription payment %s.', 'used in order note', 'woocommerce-subscriptions' ), $transaction_details['payment_status'] ) ); + + $subscription->payment_failed(); + } + + WC_Gateway_Paypal::log( 'IPN subscription payment failed for subscription ' . $subscription->id ); + + } else { + + WC_Gateway_Paypal::log( 'IPN subscription payment notification received for subscription ' . $subscription->id . ' with status ' . $transaction_details['payment_status'] ); + + } + + break; + + // Admins can suspend subscription at PayPal triggering this IPN + case 'recurring_payment_suspended': + + if ( $subscription->has_status( 'active' ) ) { + + // We don't need to suspend the subscription at PayPal because it's already on-hold there + remove_action( 'woocommerce_subscription_on-hold_paypal', 'WCS_PayPal_Status_Manager::suspend_subscription' ); + + $subscription->update_status( 'on-hold', __( 'IPN subscription suspended.', 'woocommerce-subscriptions' ) ); + + add_action( 'woocommerce_subscription_on-hold_paypal', 'WCS_PayPal_Status_Manager::suspend_subscription' ); + + WC_Gateway_Paypal::log( 'IPN subscription suspended for subscription ' . $subscription->id ); + + } else { + + WC_Gateway_Paypal::log( sprintf( 'IPN "recurring_payment_suspended" ignored for subscription %d. Subscription already %s.', $subscription->id, $subscription->get_status() ) ); + + } + + break; + + case 'subscr_cancel': + + // Make sure the subscription hasn't been linked to a new payment method + if ( wcs_get_paypal_id( $subscription ) != $transaction_details['subscr_id'] ) { + + WC_Gateway_Paypal::log( 'IPN subscription cancellation request ignored - new PayPal Profile ID linked to this subscription, for subscription ' . $subscription->id ); + + } else { + + $subscription->cancel_order( __( 'IPN subscription cancelled.', 'woocommerce-subscriptions' ) ); + + WC_Gateway_Paypal::log( 'IPN subscription cancelled for subscription ' . $subscription->id ); + + } + + break; + + case 'subscr_eot': // Subscription ended, either due to failed payments or expiration + + WC_Gateway_Paypal::log( 'IPN EOT request ignored for subscription ' . $subscription->id ); + break; + + case 'subscr_failed': // Subscription sign up failed + case 'recurring_payment_suspended_due_to_max_failed_payment': // Recurring payment failed + + $ipn_failure_note = __( 'IPN subscription payment failure.', 'woocommerce-subscriptions' ); + + if ( ! $is_first_payment && ! $is_renewal_sign_up_after_failure && $subscription->has_status( 'active' ) ) { + // Generate a renewal order to record the failed payment + $renewal_order = wcs_create_renewal_order( $subscription ); + + // Set PayPal as the payment method + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + $renewal_order->set_payment_method( $available_gateways['paypal'] ); + $renewal_order->add_order_note( $ipn_failure_note ); + } + + WC_Gateway_Paypal::log( 'IPN subscription payment failure for subscription ' . $subscription->id ); + + // Subscription Payment completed + $subscription->add_order_note( $ipn_failure_note ); + + try { + $subscription->payment_failed(); + } catch ( Exception $e ) { + WC_Gateway_Paypal::log( sprintf( 'IPN subscription payment failure, unable to process payment failure. Exception: %s ', $e->getMessage() ) ); + } + + break; + } + + // Store the transaction IDs to avoid handling requests duplicated by PayPal + if ( isset( $transaction_details['ipn_track_id'] ) ) { + $handled_ipn_requests[] = $ipn_id; + update_post_meta( $subscription->id, '_paypal_ipn_tracking_ids', $handled_ipn_requests ); + } + + if ( isset( $transaction_details['txn_id'] ) ) { + $handled_transactions[] = $transaction_id; + update_post_meta( $subscription->id, '_paypal_transaction_ids', $handled_transactions ); + } + + // And delete the transient that's preventing other IPN's being processed + if ( isset( $ipn_lock_transient_name ) ) { + delete_transient( $ipn_lock_transient_name ); + } + + // Log completion + $log_message = 'IPN subscription request processed for ' . $subscription->id; + + if ( isset( $ipn_id ) && ! empty( $ipn_id ) ) { + $log_message .= sprintf( ' (%s)', $ipn_id ); + } + + WC_Gateway_Paypal::log( $log_message ); + + // Prevent default IPN handling for subscription txn_types + exit; + } + + /** + * Return valid transaction types + * + * @since 2.0 + */ + public function get_transaction_types() { + return $this->transaction_types; + } + + /** + * Checks a set of args and derives an Order ID with backward compatibility for WC < 1.7 where 'custom' was the Order ID. + * + * @since 2.0 + */ + public static function get_order_id_and_key( $args, $order_type = 'shop_order' ) { + + $order_id = $order_key = ''; + + if ( isset( $args['subscr_id'] ) ) { // PayPal Standard IPN message + $subscription_id = $args['subscr_id']; + } elseif ( isset( $args['recurring_payment_id'] ) ) { // PayPal Express Checkout IPN, most likely 'recurring_payment_suspended_due_to_max_failed_payment', for a PayPal Standard Subscription + $subscription_id = $args['recurring_payment_id']; + } else { + $subscription_id = ''; + } + + // First try and get the order ID by the subscription ID + if ( ! empty( $subscription_id ) ) { + + $posts = get_posts( array( + 'numberposts' => 1, + 'orderby' => 'ID', + 'order' => 'ASC', + 'meta_key' => '_paypal_subscription_id', + 'meta_value' => $subscription_id, + 'post_type' => $order_type, + 'post_status' => 'any', + 'suppress_filters' => true, + ) ); + + if ( ! empty( $posts ) ) { + $order_id = $posts[0]->ID; + $order_key = get_post_meta( $order_id, '_order_key', true ); + } + } + + // Couldn't find the order ID by subscr_id, so it's either not set on the order yet or the $args doesn't have a subscr_id, either way, let's get it from the args + if ( empty( $order_id ) && isset( $args['custom'] ) ) { + // WC < 1.6.5 + if ( is_numeric( $args['custom'] ) && 'shop_order' == $order_type ) { + + $order_id = $args['custom']; + $order_key = $args['invoice']; + + } else { + + $order_details = json_decode( $args['custom'] ); + + if ( is_object( $order_details ) ) { // WC 2.3.11+ converted the custom value to JSON, if we have an object, we've got valid JSON + + if ( 'shop_order' == $order_type ) { + $order_id = $order_details->order_id; + $order_key = $order_details->order_key; + } elseif ( isset( $order_details->subscription_id ) ) { + // Subscription created with Subscriptions 2.0+ + $order_id = $order_details->subscription_id; + $order_key = $order_details->subscription_key; + } else { + // Subscription created with Subscriptions < 2.0 + $subscriptions = wcs_get_subscriptions_for_order( $order_details->order_id, array( 'order_type' => array( 'parent' ) ) ); + + if ( ! empty( $subscriptions ) ) { + $subscription = array_pop( $subscriptions ); + $order_id = $subscription->id; + $order_key = $subscription->order_key; + } + } + } elseif ( preg_match( '/^a:2:{/', $args['custom'] ) && ! preg_match( '/[CO]:\+?[0-9]+:"/', $args['custom'] ) && ( $order_details = maybe_unserialize( $args['custom'] ) ) ) { // WC 2.0 - WC 2.3.11, only allow serialized data in the expected format, do not allow objects or anything nasty to sneak in + + if ( 'shop_order' == $order_type ) { + $order_id = $order_details[0]; + $order_key = $order_details[1]; + } else { + + // Subscription, but we didn't have the subscription data in old, serialized value, so we need to pull it based on the order + $subscriptions = wcs_get_subscriptions_for_order( $order_details[0], array( 'order_type' => array( 'parent' ) ) ); + + if ( ! empty( $subscriptions ) ) { + $subscription = array_pop( $subscriptions ); + $order_id = $subscription->id; + $order_key = $subscription->order_key; + } + } + } else { // WC 1.6.5 - WC 2.0 or invalid data + + $order_id = str_replace( WCS_PayPal::get_option( 'invoice_prefix' ), '', $args['invoice'] ); + $order_key = $args['custom']; + + } + } + } + + return array( 'order_id' => (int) $order_id, 'order_key' => $order_key ); + } + + /** + * Cancel a specific PayPal Standard Subscription Profile with PayPal. + * + * Used when switching payment methods with PayPal Standard to make sure that + * the old subscription's profile ID is cancelled, not the new one. + * + * @param WC_Subscription A subscription object + * @param string A PayPal Subscription Profile ID + * @since 2.0 + */ + protected static function cancel_subscription( $subscription, $old_paypal_subscriber_id ) { + + // No need to cancel billing agreements + if ( wcs_is_paypal_profile_a( $old_paypal_subscriber_id, 'billing_agreement' ) ) { + return; + } + + $current_profile_id = wcs_get_paypal_id( $subscription->id ); + + // Update the subscription using the old profile ID + wcs_set_paypal_id( $subscription, $old_paypal_subscriber_id ); + + // Call update_subscription_status() directly as we don't want the notes added by WCS_PayPal_Status_Manager::cancel_subscription() + WCS_PayPal_Status_Manager::update_subscription_status( $subscription, 'Cancel' ); + + // Restore the current profile ID + wcs_set_paypal_id( $subscription, $current_profile_id ); + } + + /** + * Check for a valid transaction type + * + * @param string $txn_type + * @since 2.0 + */ + protected function validate_transaction_type( $txn_type ) { + if ( in_array( strtolower( $txn_type ), $this->get_transaction_types() ) ) { + return true; + } else { + return false; + } + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-standard-request.php b/includes/gateways/paypal/includes/class-wcs-paypal-standard-request.php new file mode 100644 index 0000000..1f92780 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-standard-request.php @@ -0,0 +1,274 @@ +id ) ); + $subscription = array_pop( $subscriptions ); + $order = $subscription->order; + + // We need the subscription's total + remove_filter( 'woocommerce_order_amount_total', 'WC_Subscriptions_Change_Payment_Gateway::maybe_zero_total', 11, 2 ); + + } else { + + // Otherwise the order is the $order + if ( $cart_item = wcs_cart_contains_failed_renewal_order_payment() || false !== WC_Subscriptions_Renewal_Order::get_failed_order_replaced_by( $order->id ) ) { + $subscriptions = wcs_get_subscriptions_for_renewal_order( $order ); + $order_contains_failed_renewal = true; + } else { + $subscriptions = wcs_get_subscriptions_for_order( $order ); + } + + // Only one subscription allowed per order with PayPal + $subscription = array_pop( $subscriptions ); + } + + if ( $order_contains_failed_renewal || ( ! empty( $subscription ) && $subscription->get_total() > 0 && 'yes' !== get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) ) ) { + + // It's a subscription + $paypal_args['cmd'] = '_xclick-subscriptions'; + + // Store the subscription ID in the args sent to PayPal so we can access them later + $paypal_args['custom'] = wcs_json_encode( array( 'order_id' => $order->id, 'order_key' => $order->order_key, 'subscription_id' => $subscription->id, 'subscription_key' => $subscription->order_key ) ); + + foreach ( $subscription->get_items() as $item ) { + if ( $item['qty'] > 1 ) { + $item_names[] = $item['qty'] . ' x ' . wcs_get_paypal_item_name( $item['name'] ); + } elseif ( $item['qty'] > 0 ) { + $item_names[] = wcs_get_paypal_item_name( $item['name'] ); + } + } + + // translators: 1$: subscription ID, 2$: order ID, 3$: names of items, comma separated + $paypal_args['item_name'] = wcs_get_paypal_item_name( sprintf( _x( 'Subscription %1$s (Order %2$s) - %3$s', 'item name sent to paypal', 'woocommerce-subscriptions' ), $subscription->get_order_number(), $order->get_order_number(), implode( ', ', $item_names ) ) ); + + $unconverted_periods = array( + 'billing_period' => $subscription->billing_period, + 'trial_period' => $subscription->trial_period, + ); + + $converted_periods = array(); + + // Convert period strings into PayPay's format + foreach ( $unconverted_periods as $key => $period ) { + switch ( strtolower( $period ) ) { + case 'day': + $converted_periods[ $key ] = 'D'; + break; + case 'week': + $converted_periods[ $key ] = 'W'; + break; + case 'year': + $converted_periods[ $key ] = 'Y'; + break; + case 'month': + default: + $converted_periods[ $key ] = 'M'; + break; + } + } + + $price_per_period = $subscription->get_total(); + $subscription_interval = $subscription->billing_interval; + $start_timestamp = $subscription->get_time( 'start' ); + $trial_end_timestamp = $subscription->get_time( 'trial_end' ); + $next_payment_timestamp = $subscription->get_time( 'next_payment' ); + + $is_synced_subscription = WC_Subscriptions_Synchroniser::subscription_contains_synced_product( $subscription->id ); + + if ( $is_synced_subscription ) { + $length_from_timestamp = $next_payment_timestamp; + } elseif ( $trial_end_timestamp > 0 ) { + $length_from_timestamp = $trial_end_timestamp; + } else { + $length_from_timestamp = $start_timestamp; + } + + $subscription_length = wcs_estimate_periods_between( $length_from_timestamp, $subscription->get_time( 'end' ), $subscription->billing_period ); + + $subscription_installments = $subscription_length / $subscription_interval; + + $initial_payment = ( $is_payment_change ) ? 0 : $order->get_total(); + + if ( $order_contains_failed_renewal || $is_payment_change ) { + + if ( $is_payment_change ) { + // Add a nonce to the order ID to avoid "This invoice has already been paid" error when changing payment method to PayPal when it was previously PayPal + $suffix = '-wcscpm-' . wp_create_nonce(); + } else { + // Failed renewal order, append a descriptor and renewal order's ID + $suffix = '-wcsfrp-' . $order->id; + } + + // Change the 'invoice' and the 'custom' values to be for the original order (if there is one) + if ( false === $subscription->order ) { + // No original order so we need to use the subscriptions values instead + $order_number = ltrim( $subscription->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ) . '-subscription'; + $order_id_key = array( 'order_id' => $subscription->id, 'order_key' => $subscription->order_key ); + } else { + $order_number = ltrim( $subscription->order->get_order_number(), _x( '#', 'hash before the order number. Used as a character to remove from the actual order number', 'woocommerce-subscriptions' ) ); + $order_id_key = array( 'order_id' => $subscription->order->id, 'order_key' => $subscription->order->order_key ); + } + + $order_details = ( false !== $subscription->order ) ? $subscription->order : $subscription; + + // Set the invoice details to the original order's invoice but also append a special string and this renewal orders ID so that we can match it up as a failed renewal order payment later + $paypal_args['invoice'] = WCS_PayPal::get_option( 'invoice_prefix' ) . $order_number . $suffix; + $paypal_args['custom'] = wcs_json_encode( array_merge( $order_id_key, array( 'subscription_id' => $subscription->id, 'subscription_key' => $subscription->order_key ) ) ); + + } + + if ( $order_contains_failed_renewal ) { + + $subscription_trial_length = 0; + $subscription_installments = max( $subscription_installments - $subscription->get_completed_payment_count(), 0 ); + + // If we're changing the payment date or switching subs, we need to set the trial period to the next payment date & installments to be the number of installments left + } elseif ( $is_payment_change || $is_synced_subscription ) { + + $next_payment_timestamp = $subscription->get_time( 'next_payment' ); + + // When the subscription is on hold + if ( false != $next_payment_timestamp && ! empty( $next_payment_timestamp ) ) { + + $trial_until = wcs_calculate_paypal_trial_periods_until( $next_payment_timestamp ); + + $subscription_trial_length = $trial_until['first_trial_length']; + $converted_periods['trial_period'] = $trial_until['first_trial_period']; + + $second_trial_length = $trial_until['second_trial_length']; + $second_trial_period = $trial_until['second_trial_period']; + + } else { + + $subscription_trial_length = 0; + + } + + // If this is a payment change, we need to account for completed payments on the number of installments owing + if ( $is_payment_change && $subscription_length > 0 ) { + $subscription_installments = max( $subscription_installments - $subscription->get_completed_payment_count(), 0 ); + } + } else { + + $subscription_trial_length = wcs_estimate_periods_between( $start_timestamp, $trial_end_timestamp, $subscription->trial_period ); + + } + + if ( $subscription_trial_length > 0 ) { // Specify a free trial period + + $paypal_args['a1'] = ( $initial_payment > 0 ) ? $initial_payment : 0; + + // Trial period length + $paypal_args['p1'] = $subscription_trial_length; + + // Trial period + $paypal_args['t1'] = $converted_periods['trial_period']; + + // We need to use a second trial period before we have more than 90 days until the next payment + if ( isset( $second_trial_length ) && $second_trial_length > 0 ) { + $paypal_args['a2'] = 0.01; // Alas, although it's undocumented, PayPal appears to require a non-zero value in order to allow a second trial period + $paypal_args['p2'] = $second_trial_length; + $paypal_args['t2'] = $second_trial_period; + } + } elseif ( $initial_payment != $price_per_period ) { // No trial period, but initial amount includes a sign-up fee and/or other items, so charge it as a separate period + + if ( 1 == $subscription_installments ) { + $param_number = 3; + } else { + $param_number = 1; + } + + $paypal_args[ 'a' . $param_number ] = $initial_payment; + + // Sign Up interval + $paypal_args[ 'p' . $param_number ] = $subscription_interval; + + // Sign Up unit of duration + $paypal_args[ 't' . $param_number ] = $converted_periods['billing_period']; + + } + + // We have a recurring payment + if ( ! isset( $param_number ) || 1 == $param_number ) { + + // Subscription price + $paypal_args['a3'] = $price_per_period; + + // Subscription duration + $paypal_args['p3'] = $subscription_interval; + + // Subscription period + $paypal_args['t3'] = $converted_periods['billing_period']; + + } + + // Recurring payments + if ( 1 == $subscription_installments || ( $initial_payment != $price_per_period && 0 == $subscription_trial_length && 2 == $subscription_installments ) ) { + + // Non-recurring payments + $paypal_args['src'] = 0; + + } else { + + $paypal_args['src'] = 1; + + if ( $subscription_installments > 0 ) { + + // An initial period is being used to charge a sign-up fee + if ( $initial_payment != $price_per_period && 0 == $subscription_trial_length ) { + $subscription_installments--; + } + + $paypal_args['srt'] = $subscription_installments; + + } + } + + // Don't reattempt failed payments, instead let Subscriptions handle the failed payment + $paypal_args['sra'] = 0; + + // Force return URL so that order description & instructions display + $paypal_args['rm'] = 2; + + // Reattach the filter we removed earlier + if ( $is_payment_change ) { + add_filter( 'woocommerce_order_amount_total', 'WC_Subscriptions_Change_Payment_Gateway::maybe_zero_total', 11, 2 ); + } + } + + return $paypal_args; + } + +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-standard-switcher.php b/includes/gateways/paypal/includes/class-wcs-paypal-standard-switcher.php new file mode 100644 index 0000000..ad62cd4 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-standard-switcher.php @@ -0,0 +1,232 @@ +payment_method && WCS_PayPal::are_reference_transactions_enabled() ) { + + $is_billing_agreement = wcs_is_paypal_profile_a( wcs_get_paypal_id( $subscription->id ), 'billing_agreement' ); + + if ( 'line_item' == $item['type'] && wcs_is_product_switchable_type( $item['product_id'] ) ) { + $is_product_switchable = true; + } else { + $is_product_switchable = false; + } + + if ( $subscription->has_status( 'active' ) && 0 !== $subscription->get_date( 'last_payment' ) ) { + $is_subscription_switchable = true; + } else { + $is_subscription_switchable = false; + } + + // If the only reason the subscription isn't switchable is because the PayPal profile ID is not a billing agreement, allow it to be switched + if ( false === $is_billing_agreement && $is_product_switchable && $is_subscription_switchable ) { + $item_can_be_switch = true; + } + } + + return $item_can_be_switch; + } + + /** + * Check whether the cart needs payment even if the order total is $0 because it's a subscription switch request for a subscription using + * PayPal Standard as the subscription. + * + * @param bool $needs_payment The existing flag for whether the cart needs payment or not. + * @param WC_Cart $cart The WooCommerce cart object. + * @return bool + */ + public static function cart_needs_payment( $needs_payment, $cart ) { + + $cart_switch_items = WC_Subscriptions_Switcher::cart_contains_switches(); + + if ( false === $needs_payment && 0 == $cart->total && false !== $cart_switch_items && 'yes' !== get_option( WC_Subscriptions_Admin::$option_prefix . '_turn_off_automatic_payments', 'no' ) ) { + + foreach ( $cart_switch_items as $cart_switch_details ) { + + $subscription = wcs_get_subscription( $cart_switch_details['subscription_id'] ); + + if ( 'paypal' === $subscription->payment_method && ! wcs_is_paypal_profile_a( wcs_get_paypal_id( $subscription->id ), 'billing_agreement' ) ) { + $needs_payment = true; + break; + } + } + } + + return $needs_payment; + } + + /** + * If switching a subscription using PayPal Standard as the payment method and the customer has entered + * in a payment method other than PayPal (which would be using Reference Transactions), make sure to update + * the payment method on the subscription (this is hooked to 'woocommerce_payment_successful_result' to make + * sure it happens after the payment succeeds). + * + * @param array $payment_processing_result The result of the process payment gateway extension request. + * @param int $order_id The ID of an order potentially recording a switch. + * @return array + */ + public static function maybe_set_payment_method( $payment_processing_result, $order_id ) { + + if ( wcs_order_contains_switch( $order_id ) ) { + + $order = wc_get_order( $order_id ); + + foreach ( wcs_get_subscriptions_for_switch_order( $order_id ) as $subscription ) { + + if ( 'paypal' === $subscription->payment_method && $subscription->payment_method !== $order->payment_method && false === wcs_is_paypal_profile_a( wcs_get_paypal_id( $subscription->id ), 'billing_agreement' ) ) { + + // Set the new payment method on the subscription + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + + if ( isset( $available_gateways[ $order->payment_method ] ) ) { + $subscription->set_payment_method( $available_gateways[ $order->payment_method ] ); + } + } + } + } + + return $payment_processing_result; + } + + /** + * Stores the old paypal standard subscription id on the switch order so that it can be used later to cancel the recurring payment. + * + * Strictly hooked on after WC_Subscriptions_Switcher::add_order_meta() + * + * @param int $order_id + * @param array $posted + * @since 2.0.15 + */ + public static function save_old_paypal_meta( $order_id, $posted ) { + + if ( wcs_order_contains_switch( $order_id ) ) { + $subscriptions = wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'switch' ) ); + + foreach ( $subscriptions as $subscription ) { + + if ( 'paypal' === $subscription->payment_method ) { + + $paypal_id = wcs_get_paypal_id( $subscription->id ); + + if ( ! wcs_is_paypal_profile_a( $paypal_id, 'billing_agreement' ) ) { + update_post_meta( $order_id, '_old_payment_method', 'paypal_standard' ); + update_post_meta( $order_id, '_old_paypal_subscription_id', $paypal_id ); + } + } + } + } + } + + /** + * Cancel subscriptions with PayPal Standard after the order has been successfully switched. + * + * @param int $order_id + * @param string $old_status + * @param string $new_status + * @since 2.0.15 + */ + public static function maybe_cancel_paypal_after_switch( $order_id, $old_status, $new_status ) { + + $order_completed = in_array( $new_status, array( apply_filters( 'woocommerce_payment_complete_order_status', 'processing', $order_id ), 'processing', 'completed' ) ) && in_array( $old_status, apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( 'pending', 'on-hold', 'failed' ) ) ); + + if ( $order_completed && wcs_order_contains_switch( $order_id ) && 'paypal_standard' == get_post_meta( $order_id, '_old_payment_method', true ) ) { + + $old_profile_id = get_post_meta( $order_id, '_old_paypal_subscription_id', true ); + + if ( ! empty( $old_profile_id ) ) { + + $subscriptions = wcs_get_subscriptions_for_order( $order_id, array( 'order_type' => 'switch' ) ); + + foreach ( $subscriptions as $subscription ) { + + if ( ! wcs_is_paypal_profile_a( $old_profile_id, 'billing_agreement' ) ) { + + $new_payment_method = $subscription->payment_method; + $new_profile_id = get_post_meta( $subscription->id, '_paypal_subscription_id', true ); // grab the current paypal subscription id in case it's a billing agreement + + update_post_meta( $subscription->id, '_payment_method', 'paypal' ); + update_post_meta( $subscription->id, '_paypal_subscription_id', $old_profile_id ); + + WCS_PayPal_Status_Manager::suspend_subscription( $subscription ); + + // restore payment meta to the new data + update_post_meta( $subscription->id, '_payment_method', $new_payment_method ); + update_post_meta( $subscription->id, '_paypal_subscription_id', $new_profile_id ); + } + } + } + } + } + + /** + * Do not allow subscriptions to be switched using PayPal Standard as the payment method + * + * @since 2.0.16 + */ + public static function get_available_payment_gateways( $available_gateways ) { + + if ( WC_Subscriptions_Switcher::cart_contains_switches() || ( isset( $_GET['order_id'] ) && wcs_order_contains_switch( $_GET['order_id'] ) ) ) { + foreach ( $available_gateways as $gateway_id => $gateway ) { + if ( 'paypal' == $gateway_id && false == WCS_PayPal::are_reference_transactions_enabled() ) { + unset( $available_gateways[ $gateway_id ] ); + } + } + } + + return $available_gateways; + } +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-status-manager.php b/includes/gateways/paypal/includes/class-wcs-paypal-status-manager.php new file mode 100644 index 0000000..fd6f7dd --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-status-manager.php @@ -0,0 +1,116 @@ +id ), 'billing_agreement' ) && self::update_subscription_status( $subscription, 'Cancel' ) ) { + $subscription->add_order_note( __( 'Subscription cancelled with PayPal', 'woocommerce-subscriptions' ) ); + } + } + + /** + * When a store manager or user suspends a subscription in the store, also suspend the subscription with PayPal. + * + * @since 2.0 + */ + public static function suspend_subscription( $subscription ) { + if ( ! wcs_is_paypal_profile_a( wcs_get_paypal_id( $subscription->id ), 'billing_agreement' ) && self::update_subscription_status( $subscription, 'Suspend' ) ) { + $subscription->add_order_note( __( 'Subscription suspended with PayPal', 'woocommerce-subscriptions' ) ); + } + } + + /** + * When a store manager or user reactivates a subscription in the store, also reactivate the subscription with PayPal. + * + * How PayPal Handles suspension is discussed here: https://www.x.com/developers/paypal/forums/nvp/reactivate-recurring-profile + * + * @since 2.0 + */ + public static function reactivate_subscription( $subscription ) { + if ( ! wcs_is_paypal_profile_a( wcs_get_paypal_id( $subscription->id ), 'billing_agreement' ) && self::update_subscription_status( $subscription, 'Reactivate' ) ) { + $subscription->add_order_note( __( 'Subscription reactivated with PayPal', 'woocommerce-subscriptions' ) ); + } + } + + /** + * Performs an Express Checkout NVP API operation as passed in $api_method. + * + * Although the PayPal Standard API provides no facility for cancelling a subscription, the PayPal + * Express Checkout NVP API can be used. + * + * @since 2.0 + */ + public static function update_subscription_status( $subscription, $new_status ) { + + $profile_id = wcs_get_paypal_id( $subscription->id ); + + if ( wcs_is_paypal_profile_a( $profile_id, 'billing_agreement' ) ) { + + // Nothing to do here, leave the billing agreement active at PayPal for use with other subscriptions and just change the status in the store + $status_updated = true; + + } elseif ( ! empty( $profile_id ) ) { + + // We need to change the subscriptions status at PayPal, which is doing via Express Checkout APIs, despite the subscription having been created with PayPal Standard + $response = self::get_api()->manage_recurring_payments_profile_status( $profile_id, $new_status, $subscription ); + + if ( ! $response->has_api_error() ) { + $status_updated = true; + } else { + $status_updated = false; + } + } else { + $status_updated = false; + } + + return $status_updated; + } + + /** + * When changing the payment method on edit subscription screen from PayPal, only suspend the subscription rather + * than cancelling it. + * + * @param string $status The subscription status sent to the current payment gateway before changing subscription payment method. + * @return object $subscription + * @since 2.0 + */ + public static function suspend_subscription_on_payment_changed( $status, $subscription ) { + return ( 'paypal' == $subscription->payment_gateway->id ) ? 'on-hold' : $status; + } + +} diff --git a/includes/gateways/paypal/includes/class-wcs-paypal-supports.php b/includes/gateways/paypal/includes/class-wcs-paypal-supports.php new file mode 100644 index 0000000..7541f67 --- /dev/null +++ b/includes/gateways/paypal/includes/class-wcs-paypal-supports.php @@ -0,0 +1,110 @@ +id && WCS_PayPal::are_credentials_set() ) { + + if ( in_array( $feature, self::$standard_supported_features ) ) { + $is_supported = true; + } elseif ( in_array( $feature, self::$reference_transaction_supported_features ) && WCS_PayPal::are_reference_transactions_enabled() ) { + $is_supported = true; + } + } + + return $is_supported; + } + + /** + * Add additional feature support at the subscription level instead of just the gateway level because some subscriptions may have been + * setup with PayPal Standard while others may have been setup with Billing Agreements to use with Reference Transactions. + * + * @since 2.0 + */ + public static function add_feature_support_for_subscription( $is_supported, $feature, $subscription ) { + + if ( 'paypal' === $subscription->payment_method && WCS_PayPal::are_credentials_set() ) { + + $paypal_profile_id = wcs_get_paypal_id( $subscription->id ); + $is_billing_agreement = wcs_is_paypal_profile_a( $paypal_profile_id, 'billing_agreement' ); + + if ( 'gateway_scheduled_payments' === $feature && $is_billing_agreement ) { + + $is_supported = false; + + } elseif ( in_array( $feature, self::$standard_supported_features ) ) { + + if ( wcs_is_paypal_profile_a( $paypal_profile_id, 'out_of_date_id' ) ) { + $is_supported = false; + } else { + $is_supported = true; + } + } elseif ( in_array( $feature, self::$reference_transaction_supported_features ) ) { + + if ( $is_billing_agreement ) { + $is_supported = true; + } else { + $is_supported = false; + } + } + } + + return $is_supported; + } + +} diff --git a/includes/gateways/paypal/includes/deprecated/class-wc-paypal-standard-subscriptions.php b/includes/gateways/paypal/includes/deprecated/class-wc-paypal-standard-subscriptions.php new file mode 100644 index 0000000..f9125d9 --- /dev/null +++ b/includes/gateways/paypal/includes/deprecated/class-wc-paypal-standard-subscriptions.php @@ -0,0 +1,280 @@ +manage_recurring_payments_profile_status()' ); + return WCS_PayPal::get_api()->manage_recurring_payments_profile_status( $profile_id, $new_status, $order ); + } + + /** + * Checks a set of args and derives an Order ID with backward compatibility for WC < 1.7 where 'custom' was the Order ID. + * + * @since 1.2 + */ + public static function get_order_id_and_key( $args ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Paypal_Standard_IPN_Handler::get_order_id_and_key()' ); + return WCS_Paypal_Standard_IPN_Handler::get_order_id_and_key( $args, 'shop_order' ); + } + + /** + * If changing a subscriptions payment method from and to PayPal, wait until an appropriate IPN message + * has come in before deciding to cancel the old subscription. + * + * @since 2.0 + */ + public static function maybe_remove_subscription_cancelled_callback( $subscription, $new_payment_method, $old_payment_method ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * If changing a subscriptions payment method from and to PayPal, the cancelled subscription hook was removed in + * @see self::maybe_remove_cancelled_subscription_hook() so we want to add it again for other subscriptions. + * + * @since 2.0 + */ + public static function maybe_reattach_subscription_cancelled_callback( $subscription, $new_payment_method, $old_payment_method ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Don't update the payment method on checkout when switching to PayPal - wait until we have the IPN message. + * + * @param string $item_name + * @return string + * @since 1.5.14 + */ + public static function maybe_dont_update_payment_method( $update, $new_payment_method ) { + _deprecated_function( __METHOD__, '2.0' ); + return $update; + } + + /** + * In typical PayPal style, there are a couple of important limitations we need to work around: + * + * @since 1.4.3 + */ + public static function scheduled_subscription_payment() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Prompt the store manager to enter their PayPal API credentials if they are using + * PayPal and have yet not entered their API credentials. + * + * @return void + */ + public static function maybe_show_admin_notice() { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * When a store manager or user cancels a subscription in the store, also cancel the subscription with PayPal. + * + * @since 1.1 + */ + public static function cancel_subscription_with_paypal( $order, $product_id = '', $profile_id = '' ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_PayPal_Status_Manager::cancel_subscription( $subscription )' ); + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + self::change_subscription_status( $profile_id, 'Cancel', $subscription ); + } + } + + /** + * When a store manager or user suspends a subscription in the store, also suspend the subscription with PayPal. + * + * @since 1.1 + */ + public static function suspend_subscription_with_paypal( $order, $product_id ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_PayPal_Status_Manager::suspend_subscription( $subscription )' ); + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + WCS_PayPal_Status_Manager::suspend_subscription( $subscription ); + } + } + + /** + * When a store manager or user reactivates a subscription in the store, also reactivate the subscription with PayPal. + * + * How PayPal Handles suspension is discussed here: https://www.x.com/developers/paypal/forums/nvp/reactivate-recurring-profile + * + * @since 1.1 + */ + public static function reactivate_subscription_with_paypal( $order, $product_id ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_PayPal::reactivate_subscription( $subscription )' ); + foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'parent' ) ) as $subscription ) { + WCS_PayPal_Status_Manager::reactivate_subscription( $subscription ); + } + } + + /** + * Don't transfer PayPal customer/token meta when creating a parent renewal order. + * + * @access public + * @param array $order_meta_query MySQL query for pulling the metadata + * @param int $original_order_id Post ID of the order being used to purchased the subscription being renewed + * @param int $renewal_order_id Post ID of the order created for renewing the subscription + * @param string $new_order_role The role the renewal order is taking, one of 'parent' or 'child' + * @return void + */ + public static function remove_renewal_order_meta( $order_meta_query, $original_order_id, $renewal_order_id, $new_order_role ) { + _deprecated_function( __METHOD__, '2.0' ); + + if ( 'parent' == $new_order_role ) { + $order_meta_query .= ' AND `meta_key` NOT IN (' + . "'Transaction ID', " + . "'Payer first name', " + . "'Payer last name', " + . "'Payment type', " + . "'Payer PayPal address', " + . "'Payer PayPal first name', " + . "'Payer PayPal last name', " + . "'PayPal Subscriber ID' )"; + } + + return $order_meta_query; + } + + /** + * If changing a subscriptions payment method from and to PayPal, wait until an appropriate IPN message + * has come in before deciding to cancel the old subscription. + * + * @since 1.4.6 + */ + public static function maybe_remove_cancelled_subscription_hook( $order, $subscription_key, $new_payment_method, $old_payment_method ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * If changing a subscriptions payment method from and to PayPal, the cancelled subscription hook was removed in + * @see self::maybe_remove_cancelled_subscription_hook() so we want to add it again for other subscriptions. + * + * @since 1.4.6 + */ + public static function maybe_readd_cancelled_subscription_hook( $order, $subscription_key, $new_payment_method, $old_payment_method ) { + _deprecated_function( __METHOD__, '2.0' ); + } + + /** + * Takes a timestamp for a date in the future and calculates the number of days between now and then + * + * @since 1.4 + */ + public static function calculate_trial_periods_until( $future_timestamp ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_calculate_paypal_trial_periods_until()' ); + return wcs_calculate_paypal_trial_periods_until( $future_timestamp ); + } +} +add_action( 'init', 'WC_PayPal_Standard_Subscriptions::init', 11 ); + +/** + * Needs to be called after init so that $woocommerce global is setup + **/ +function create_paypal_standard_subscriptions() { + _deprecated_function( __METHOD__, '2.0', 'WCS_PayPal::init()' ); + WC_PayPal_Standard_Subscriptions::init(); +} diff --git a/includes/gateways/paypal/includes/templates/admin-notices.php b/includes/gateways/paypal/includes/templates/admin-notices.php new file mode 100644 index 0000000..be20763 --- /dev/null +++ b/includes/gateways/paypal/includes/templates/admin-notices.php @@ -0,0 +1,33 @@ + + + '; + break; + case 'error' : + echo '

    '; + break; + case 'confirmation' : + default : + echo '
    '; + break; + } ?> +

    +
    +id; + } + + return get_post_meta( $order_id, '_paypal_subscription_id', true ); +} + +/** + * Stores a PayPal Standard Subscription ID or Billing Agreement ID in the post meta of a given order and the user meta of the order's user. + * + * @param int|object A WC_Order or WC_Subscription object or the ID of a WC_Order or WC_Subscription object + * @param string A PayPal Standard Subscription ID or Express Checkout Billing Agreement ID + * @since 2.0 + */ +function wcs_set_paypal_id( $order, $paypal_subscription_id ) { + + if ( ! is_object( $order ) ) { + $order = wc_get_order( $order ); + } + + if ( wcs_is_paypal_profile_a( $paypal_subscription_id, 'billing_agreement' ) ) { + if ( ! in_array( $paypal_subscription_id, get_user_meta( $order->get_user_id(), '_paypal_subscription_id', false ) ) ) { + add_user_meta( $order->get_user_id(), '_paypal_subscription_id', $paypal_subscription_id ); + } + } + + return update_post_meta( $order->id, '_paypal_subscription_id', $paypal_subscription_id ); +} + +/** + * Checks if a given profile ID is of a certain type. + * + * PayPal offers many different profile IDs that can be used for recurring payments, including: + * - Express Checkout Billing Agreement IDs for Reference Transactios + * - Express Checkout Recurring Payment profile IDs + * - PayPal Standard Subscription IDs + * - outdated PayPal Standard Subscription IDs (for accounts prior to 2009 that have not been upgraded). + * + * @param string $profile_id A PayPal Standard Subscription ID or Express Checkout Billing Agreement ID + * @param string $profile_type A type of profile ID, can be 'billing_agreement' or 'old_id'. + * @since 2.0 + */ +function wcs_is_paypal_profile_a( $profile_id, $profile_type ) { + + if ( 'billing_agreement' === $profile_type && 'B-' == substr( $profile_id, 0, 2 ) ) { + $is_a = true; + } elseif ( 'out_of_date_id' === $profile_type && 'S-' == substr( $profile_id, 0, 2 ) ) { + $is_a = true; + } else { + $is_a = false; + } + + return apply_filters( 'woocommerce_subscriptions_is_paypal_profile_a_' . $profile_type, $is_a, $profile_id ); +} + +/** + * Limit the length of item names to be within the allowed 127 character range. + * + * @param string $item_name + * @return string + * @since 2.0 + */ +function wcs_get_paypal_item_name( $item_name ) { + + if ( strlen( $item_name ) > 127 ) { + $item_name = substr( $item_name, 0, 124 ) . '...'; + } + return html_entity_decode( $item_name, ENT_NOQUOTES, 'UTF-8' ); +} + +/** + * Takes a timestamp for a date in the future and calculates the number of days between now and then + * + * @since 2.0 + */ +function wcs_calculate_paypal_trial_periods_until( $future_timestamp ) { + + $seconds_until_next_payment = $future_timestamp - gmdate( 'U' ); + $days_until_next_payment = ceil( $seconds_until_next_payment / ( 60 * 60 * 24 ) ); + + if ( $days_until_next_payment <= 90 ) { // Can't be more than 90 days free trial + + $first_trial_length = $days_until_next_payment; + $first_trial_period = 'D'; + + $second_trial_length = 0; + $second_trial_period = 'D'; + + } else { // We need to use a second trial period + + if ( $days_until_next_payment > 365 * 2 ) { // We need to use years because PayPal has a maximum of 24 months + + $first_trial_length = floor( $days_until_next_payment / 365 ); + $first_trial_period = 'Y'; + + $second_trial_length = $days_until_next_payment % 365; + $second_trial_period = 'D'; + + } elseif ( $days_until_next_payment > 365 ) { // Less than two years but more than one, use months + + $first_trial_length = floor( $days_until_next_payment / 30 ); + $first_trial_period = 'M'; + + $days_remaining = $days_until_next_payment % 30; + + if ( $days_remaining <= 90 ) { // We can use days + $second_trial_length = $days_remaining; + $second_trial_period = 'D'; + } else { // We need to use weeks + $second_trial_length = floor( $days_remaining / 7 ); + $second_trial_period = 'W'; + } + } else { // We need to use weeks + + $first_trial_length = floor( $days_until_next_payment / 7 ); + $first_trial_period = 'W'; + + $second_trial_length = $days_until_next_payment % 7; + $second_trial_period = 'D'; + + } + } + + return array( + 'first_trial_length' => $first_trial_length, + 'first_trial_period' => $first_trial_period, + 'second_trial_length' => $second_trial_length, + 'second_trial_period' => $second_trial_period, + ); +} + +/** + * Check if the $_SERVER global has PayPal WC-API endpoint URL slug in its 'REQUEST_URI' value + * + * In some cases, we need tdo be able to check if we're on the PayPal API page before $wp's query vars are setup, + * like from WC_Subscriptions_Product::is_purchasable() and WC_Product_Subscription_Variation::is_purchasable(), + * both of which are called within WC_Cart::get_cart_from_session(), which is run before query vars are setup. + * + * @return 2.0.13 + * @return bool + **/ +function wcs_is_paypal_api_page() { + return ( false !== strpos( $_SERVER['REQUEST_URI'], 'wc-api/wcs_paypal' ) ); +} diff --git a/includes/libraries/action-scheduler/action-scheduler.php b/includes/libraries/action-scheduler/action-scheduler.php new file mode 100644 index 0000000..be35b6a --- /dev/null +++ b/includes/libraries/action-scheduler/action-scheduler.php @@ -0,0 +1,30 @@ +register( '1.4-dev', 'action_scheduler_initialize_1_dot_4_dev' ); + } + + function action_scheduler_initialize_1_dot_4_dev() { + require_once('classes/ActionScheduler.php'); + ActionScheduler::init( __FILE__ ); + } + +} \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler.php b/includes/libraries/action-scheduler/classes/ActionScheduler.php new file mode 100644 index 0000000..8f34e64 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler.php @@ -0,0 +1,125 @@ +set_hook($hook); + $this->set_schedule($schedule); + $this->set_args($args); + $this->set_group($group); + } + + public function execute() { + return do_action_ref_array($this->get_hook(), $this->get_args()); + } + + /** + * @param string $hook + * @return void + */ + protected function set_hook( $hook ) { + $this->hook = $hook; + } + + public function get_hook() { + return $this->hook; + } + + protected function set_schedule( ActionScheduler_Schedule $schedule ) { + $this->schedule = $schedule; + } + + /** + * @return ActionScheduler_Schedule + */ + public function get_schedule() { + return $this->schedule; + } + + protected function set_args( array $args ) { + $this->args = $args; + } + + public function get_args() { + return $this->args; + } + + /** + * @param string $group + */ + protected function set_group( $group ) { + $this->group = $group; + } + + /** + * @return string + */ + public function get_group() { + return $this->group; + } + + /** + * @return bool If the action has been finished + */ + public function is_finished() { + return FALSE; + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_ActionClaim.php b/includes/libraries/action-scheduler/classes/ActionScheduler_ActionClaim.php new file mode 100644 index 0000000..8b56816 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_ActionClaim.php @@ -0,0 +1,23 @@ +id = $id; + $this->action_ids = $action_ids; + } + + public function get_id() { + return $this->id; + } + + public function get_actions() { + return $this->action_ids; + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_ActionFactory.php b/includes/libraries/action-scheduler/classes/ActionScheduler_ActionFactory.php new file mode 100644 index 0000000..ef01287 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_ActionFactory.php @@ -0,0 +1,72 @@ +store( $action ); + } + + /** + * @param string $hook The hook to trigger when this action runs + * @param array $args Args to pass when the hook is triggered + * @param int $first Unix timestamp for the first run + * @param int $interval Seconds between runs + * @param string $group A group to put the action in + * + * @return string The ID of the stored action + */ + public function recurring( $hook, $args = array(), $first = NULL, $interval = NULL, $group = '' ) { + if ( empty($interval) ) { + return $this->single( $hook, $args, $first, $group ); + } + $date = ActionScheduler::get_datetime_object( $first ); + $schedule = new ActionScheduler_IntervalSchedule( $date, $interval ); + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + return $this->store( $action ); + } + + + /** + * @param string $hook The hook to trigger when this action runs + * @param array $args Args to pass when the hook is triggered + * @param int $first Unix timestamp for the first run + * @param int $schedule A cron definition string + * @param string $group A group to put the action in + * + * @return string The ID of the stored action + */ + public function cron( $hook, $args = array(), $first = NULL, $schedule = NULL, $group = '' ) { + if ( empty($schedule) ) { + return $this->single( $hook, $args, $first, $group ); + } + $date = ActionScheduler::get_datetime_object( $first ); + $cron = CronExpression::factory( $schedule ); + $schedule = new ActionScheduler_CronSchedule( $date, $cron ); + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + return $this->store( $action ); + } + + /** + * @param ActionScheduler_Action $action + * + * @return string The ID of the stored action + */ + protected function store( ActionScheduler_Action $action ) { + $store = ActionScheduler_Store::instance(); + return $store->save_action( $action ); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_AdminView.php b/includes/libraries/action-scheduler/classes/ActionScheduler_AdminView.php new file mode 100644 index 0000000..1259f21 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_AdminView.php @@ -0,0 +1,498 @@ + true, + 'show_in_menu' => 'tools.php', + 'show_in_admin_bar' => false, + )); + } + + /** + * Customise the post status related views displayed on the Scheduled Actions administration screen. + * + * @param array $views An associative array of views and view labels which can be used to filter the 'scheduled-action' posts displayed on the Scheduled Actions administration screen. + * @return array $views An associative array of views and view labels which can be used to filter the 'scheduled-action' posts displayed on the Scheduled Actions administration screen. + */ + public function list_table_views( $views ) { + + foreach ( $views as $view_key => $view ) { + if ( 'publish' == $view_key ) { + $views[ $view_key ] = str_replace( __( 'Published', 'action-scheduler' ), __( 'Complete', 'action-scheduler' ), $view ); + break; + } + } + + return $views; + } + + /** + * Do not include the "Edit" action for the Scheduled Actions administration screen. + * + * Hooked to the 'bulk_actions-edit-action-scheduler' filter. + * + * @param array $actions An associative array of actions which can be performed on the 'scheduled-action' post type. + * @return array $actions An associative array of actions which can be performed on the 'scheduled-action' post type. + */ + public function bulk_actions( $actions ) { + + if ( isset( $actions['edit'] ) ) { + unset( $actions['edit'] ); + } + + return $actions; + } + + /** + * Completely customer the columns displayed on the Scheduled Actions administration screen. + * + * Because we can't filter the content of the default title and date columns, we need to recreate our own + * custom columns for displaying those post fields. For the column content, @see self::list_table_column_content(). + * + * @param array $columns An associative array of columns that are use for the table on the Scheduled Actions administration screen. + * @return array $columns An associative array of columns that are use for the table on the Scheduled Actions administration screen. + */ + public function list_table_columns( $columns ) { + + $custom_columns = array( + 'cb' => $columns['cb'], + 'hook' => __( 'Hook', 'action-scheduler' ), // because we want to customise the inline actions + 'status' => __( 'Status', 'action-scheduler' ), + 'args' => __( 'Arguments', 'action-scheduler' ), + 'taxonomy-action-group' => __( 'Group', 'action-scheduler' ), + 'recurrence' => __( 'Recurrence', 'action-scheduler' ), + 'scheduled' => __( 'Scheduled Date', 'action-scheduler' ), // because we want to customise how the date is displayed + ); + + if ( isset( $_REQUEST['post_status'] ) ) { + if ( in_array( $_REQUEST['post_status'], array( 'failed', 'in-progress' ) ) ) { + $custom_columns['modified'] = __( 'Started', 'action-scheduler' ); + } elseif ( 'publish' == $_REQUEST['post_status'] ) { + $custom_columns['modified'] = __( 'Completed', 'action-scheduler' ); + } + } + + $custom_columns['claim'] = __( 'Claim ID', 'action-scheduler' ); + $custom_columns['comments'] = __( 'Log', 'action-scheduler' ); + + return $custom_columns; + } + + /** + * Make our custom title & date columns use defaulting title & date sorting. + * + * @param array $columns An associative array of columns that can be used to sort the table on the Scheduled Actions administration screen. + * @return array $columns An associative array of columns that can be used to sort the table on the Scheduled Actions administration screen. + */ + public static function list_table_sortable_columns( $columns ) { + + $columns['hook'] = 'title'; + $columns['scheduled'] = array( 'date', true ); + $columns['modified'] = 'modified'; + $columns['claim'] = 'post_password'; + + return $columns; + } + + /** + * Print the content for our custom columns. + * + * @param string $column_name The key for the column for which we should output our content. + * @param int $post_id The ID of the 'scheduled-action' post for which this row relates. + * @return void + */ + public static function list_table_column_content( $column_name, $post_id ) { + global $post; + + $action = ActionScheduler::store()->fetch_action( $post_id ); + + $action_title = ( 'trash' == $post->post_status ) ? $post->post_title : $action->get_hook(); + $recurrence = ( 'trash' == $post->post_status ) ? 0 : $action->get_schedule(); + $next_timestamp = get_post_time( 'U', true, $post_id ); + $status = get_post_status( $post_id ); + + switch ( $column_name ) { + case 'hook': + + echo $action_title; + + $actions = array(); + + if ( current_user_can( 'edit_post', $post->ID ) && ! in_array( $post->post_status, array( 'publish', 'in-progress', 'trash' ) ) ) { + $actions['process'] = "" . __( 'Run', 'action-scheduler' ) . ""; + } + + if ( current_user_can( 'delete_post', $post->ID ) ) { + if ( 'trash' == $post->post_status ) { + $post_type_object = get_post_type_object( $post->post_type ); + $actions['untrash'] = "ID ) ), 'untrash-post_' . $post->ID ) . "'>" . __( 'Restore', 'action-scheduler' ) . ""; + } elseif ( EMPTY_TRASH_DAYS ) { + $actions['trash'] = "" . __( 'Trash', 'action-scheduler' ) . ""; + } + + if ( 'trash' == $post->post_status || !EMPTY_TRASH_DAYS ) { + $actions['delete'] = "" . __( 'Delete Permanently', 'action-scheduler' ) . ""; + } + } + + $action_count = count( $actions ); + $i = 0; + + echo '
    '; + foreach ( $actions as $a => $link ) { + ++$i; + ( $i == $action_count ) ? $sep = '' : $sep = ' | '; + echo "$link$sep"; + } + echo '
    '; + + break; + case 'status': + if ( 'publish' == $status ) { + _e( 'Complete', 'action-scheduler' ); + } else { + echo ucfirst( $status ); + } + break; + case 'args': + $action_args = ( 'trash' == $post->post_status ) ? $post->post_content : $action->get_args(); + if ( is_array( $action_args ) ) { + foreach( $action_args as $key => $value ) { + printf( "%s => %s
    ", $key, $value ); + } + } + break; + case 'recurrence': + if ( method_exists( $recurrence, 'interval_in_seconds' ) ) { + echo self::human_interval( $recurrence->interval_in_seconds() ); + } else { + _e( 'Non-repeating', 'action-scheduler' ); + } + break; + case 'scheduled': + echo get_date_from_gmt( date( 'Y-m-d H:i:s', $next_timestamp ), 'Y-m-d H:i:s' ); + if ( gmdate( 'U' ) > $next_timestamp ) { + printf( __( ' (%s ago)', 'action-scheduler' ), human_time_diff( gmdate( 'U' ), $next_timestamp ) ); + } else { + echo ' (' . human_time_diff( gmdate( 'U' ), $next_timestamp ) . ')'; + } + break; + case 'modified': + echo get_post_modified_time( 'Y-m-d H:i:s' ); + $modified_timestamp = get_post_modified_time( 'U', true ); + if ( gmdate( 'U' ) > $modified_timestamp ) { + printf( __( ' (%s ago)', 'action-scheduler' ), human_time_diff( gmdate( 'U' ), $modified_timestamp ) ); + } else { + echo ' (' . human_time_diff( gmdate( 'U' ), $modified_timestamp ) . ')'; + } + break; + case 'claim': + echo $post->post_password; + break; + } + } + + /** + * Hide the inline "Edit" action for all 'scheduled-action' posts. + * + * Hooked to the 'post_row_actions' filter. + * + * @param array $actions An associative array of actions which can be performed on the 'scheduled-action' post type. + * @return array $actions An associative array of actions which can be performed on the 'scheduled-action' post type. + */ + public static function row_actions( $actions, $post ) { + + if ( ActionScheduler_wpPostStore::POST_TYPE == $post->post_type && isset( $actions['edit'] ) ) { + unset( $actions['edit'] ); + } + + return $actions; + } + + /** + * Retrieve a URI to execute a scheduled action. + * + * @param int $action_id The ID for a 'scheduled-action' post. + * @param string $operation To run the action (including trigger before/after hooks), log the execution and update the action's status, use 'process', to simply trigger the action, use 'execute'. Default 'execute'. + * @return string The URL for running the action. + */ + private static function get_run_action_link( $action_id, $operation = 'process' ) { + + if ( !$post = get_post( $action_id ) ) + return; + + $post_type_object = get_post_type_object( $post->post_type ); + + if ( ! $post_type_object ) + return; + + if ( ! current_user_can( 'edit_post', $post->ID ) ) + return; + + $execute_link = add_query_arg( array( 'action' => $operation, 'post_id' => $post->ID ), self::$admin_url ); + + return wp_nonce_url( $execute_link, "{$operation}-action_{$post->ID}" ); + } + + /** + * Run an action when triggered from the Action Scheduler administration screen. + * + * @codeCoverageIgnore + */ + public static function maybe_execute_action() { + + if ( ! isset( $_GET['action'] ) || 'process' != $_GET['action'] || ! isset( $_GET['post_id'] ) ){ + return; + } + + $action_id = absint( $_GET['post_id'] ); + + check_admin_referer( $_GET['action'] . '-action_' . $action_id ); + + try { + ActionScheduler::runner()->process_action( $action_id ); + $success = 1; + } catch ( Exception $e ) { + $success = 0; + } + + wp_redirect( add_query_arg( array( 'executed' => $success, 'ids' => $action_id ), self::$admin_url ) ); + exit(); + } + + /** + * Convert an interval of seconds into a two part human friendly string. + * + * The WordPress human_time_diff() function only calculates the time difference to one degree, meaning + * even if an action is 1 day and 11 hours away, it will display "1 day". This funciton goes one step + * further to display two degrees of accuracy. + * + * Based on Crontrol::interval() funciton by Edward Dale: https://wordpress.org/plugins/wp-crontrol/ + * + * @param int $interval A interval in seconds. + * @return string A human friendly string representation of the interval. + */ + public static function admin_notices() { + + if ( self::is_admin_page() ) { + + if ( ActionScheduler_Store::instance()->get_claim_count() >= apply_filters( 'action_scheduler_queue_runner_concurrent_batches', 5 ) ) : ?> +
    +

    get_claim_count() ); ?>

    +
    + fetch_action( $_GET['ids'] ); + $action_hook_html = '' . $action->get_hook() . ''; + if ( 1 == $_GET['executed'] ) : ?> +
    +

    +
    + +
    +

    +
    + query['orderby'] ) && 'post_password' == $query->query['orderby'] ) { + $orderby = "$wpdb->posts.post_password " . $query->query['order']; + } + + return $orderby; + } + + /** + * Filter search queries to allow searching by Claim ID (i.e. post_password). + * + * @param string $search MySQL search string. + * @param WP_Query $query Instance of a WP_Query object + * @return string MySQL search string. + */ + public function search_post_password( $search, $query ) { + global $wpdb; + + if ( self::is_admin_page() && ! empty( $search ) ) { + + $search = ''; + + $searchand = ''; + $n = ! empty( $query->query_vars['exact'] ) ? '' : '%'; + foreach ( $query->query_vars['search_terms'] as $term ) { + $term = $wpdb->esc_like( esc_sql( $term ) ); + $search .= "{$searchand}(($wpdb->posts.post_title LIKE '{$n}{$term}{$n}') OR ($wpdb->posts.post_content LIKE '{$n}{$term}{$n}') OR ($wpdb->posts.post_password LIKE '{$n}{$term}{$n}'))"; + $searchand = ' AND '; + } + + if ( ! empty( $search ) ) { + $search = " AND ({$search}) "; + } + + } + + return $search; + } + + /** + * Change messages when a scheduled action is updated. + * + * @param array $messages + * @return array + */ + public function post_updated_messages( $messages ) { + global $post, $post_ID; + + $messages[ ActionScheduler_wpPostStore::POST_TYPE ] = array( + 0 => '', // Unused. Messages start at index 1. + 1 => __( 'Action updated.', 'action-scheduler' ), + 2 => __( 'Custom field updated.', 'action-scheduler' ), + 3 => __( 'Custom field deleted.', 'action-scheduler' ), + 4 => __( 'Action updated.', 'action-scheduler' ), + 5 => isset( $_GET['revision'] ) ? sprintf( __( 'Action restored to revision from %s', 'action-scheduler' ), wp_post_revision_title( (int) $_GET['revision'], false ) ) : false, + 6 => __( 'Action scheduled.', 'action-scheduler' ), + 7 => __( 'Action saved.', 'action-scheduler' ), + 8 => __( 'Action submitted.', 'action-scheduler' ), + 9 => sprintf( __( 'Action scheduled for: %1$s', 'action-scheduler' ), date_i18n( __( 'M j, Y @ G:i', 'action-scheduler' ), strtotime( $post->post_date ) ) ), + 10 => __( 'Action draft updated.', 'action-scheduler' ), + ); + + return $messages; + } + + /** + * Check if the current request is for the Schedul Actions administration screen. + * + * @return bool + */ + private static function is_admin_page() { + if ( is_admin() && isset( $_GET['post_type'] ) && $_GET['post_type'] == ActionScheduler_wpPostStore::POST_TYPE ) { + return true; + } else { + return false; + } + } +} \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_CronSchedule.php b/includes/libraries/action-scheduler/classes/ActionScheduler_CronSchedule.php new file mode 100644 index 0000000..33fc833 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_CronSchedule.php @@ -0,0 +1,44 @@ +start = $start; + $this->cron = $cron; + } + + /** + * @param DateTime $after + * @return DateTime|null + */ + public function next( DateTime $after = NULL ) { + $after = empty($after) ? clone($this->start) : clone($after); + return $this->cron->getNextRunDate($after, 0, TRUE); + } + + + /** + * For PHP 5.2 compat, since DateTime objects can't be serialized + * @return array + */ + public function __sleep() { + $this->start_timestamp = $this->start->format('U'); + return array( + 'start_timestamp', + 'cron' + ); + } + + public function __wakeup() { + $this->start = new DateTime('@'.$this->start_timestamp); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_FatalErrorMonitor.php b/includes/libraries/action-scheduler/classes/ActionScheduler_FatalErrorMonitor.php new file mode 100644 index 0000000..19bac0b --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_FatalErrorMonitor.php @@ -0,0 +1,54 @@ +store = $store; + } + + public function attach( ActionScheduler_ActionClaim $claim ) { + $this->claim = $claim; + add_action( 'shutdown', array( $this, 'handle_unexpected_shutdown' ) ); + add_action( 'action_scheduler_before_execute', array( $this, 'track_current_action' ), 0, 1 ); + add_action( 'action_scheduler_after_execute', array( $this, 'untrack_action' ), 0, 0 ); + add_action( 'action_scheduler_failed_execution', array( $this, 'untrack_action' ), 0, 0 ); + } + + public function detach() { + $this->claim = NULL; + $this->untrack_action(); + remove_action( 'shutdown', array( $this, 'handle_unexpected_shutdown' ) ); + remove_action( 'action_scheduler_before_execute', array( $this, 'track_current_action' ), 0, 1 ); + remove_action( 'action_scheduler_after_execute', array( $this, 'untrack_action' ), 0, 0 ); + remove_action( 'action_scheduler_failed_execution', array( $this, 'untrack_action' ), 0, 0 ); + } + + public function track_current_action( $action_id ) { + $this->action_id = $action_id; + } + + public function untrack_action() { + $this->action_id = 0; + } + + public function handle_unexpected_shutdown() { + if ( $error = error_get_last() ) { + if ( in_array( $error['type'], array( E_ERROR, E_PARSE, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ) ) ) { + if ( !empty($this->action_id) ) { + $this->store->mark_failure( $this->action_id ); + do_action( 'action_scheduler_unexpected_shutdown', $this->action_id, $error ); + } + } + $this->store->release_claim( $this->claim ); + } + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_FinishedAction.php b/includes/libraries/action-scheduler/classes/ActionScheduler_FinishedAction.php new file mode 100644 index 0000000..b23a56c --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_FinishedAction.php @@ -0,0 +1,16 @@ +start = $start; + $this->interval_in_seconds = (int)$interval; + } + + /** + * @param DateTime $after + * + * @return DateTime|null + */ + public function next( DateTime $after = NULL ) { + $after = empty($after) ? new DateTime('@0') : clone($after); + if ( $after > $this->start ) { + $after->modify('+'.$this->interval_in_seconds.' seconds'); + return $after; + } + return clone( $this->start ); + } + + /** + * @param DateTime $after + * + * @return DateTime|null + */ + public function interval_in_seconds() { + return $this->interval_in_seconds; + } + + /** + * For PHP 5.2 compat, since DateTime objects can't be serialized + * @return array + */ + public function __sleep() { + $this->start_timestamp = $this->start->format('U'); + return array( + 'start_timestamp', + 'interval_in_seconds' + ); + } + + public function __wakeup() { + $this->start = new DateTime('@'.$this->start_timestamp); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_LogEntry.php b/includes/libraries/action-scheduler/classes/ActionScheduler_LogEntry.php new file mode 100644 index 0000000..755cd6d --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_LogEntry.php @@ -0,0 +1,23 @@ +action_id = $action_id; + $this->message = $message; + } + + public function get_action_id() { + return $this->action_id; + } + + public function get_message() { + return $this->message; + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_Logger.php b/includes/libraries/action-scheduler/classes/ActionScheduler_Logger.php new file mode 100644 index 0000000..601e567 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_Logger.php @@ -0,0 +1,47 @@ +set_schedule( new ActionScheduler_NullSchedule() ); + } + + public function execute() { + // don't execute + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_NullLogEntry.php b/includes/libraries/action-scheduler/classes/ActionScheduler_NullLogEntry.php new file mode 100644 index 0000000..6f8f218 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_NullLogEntry.php @@ -0,0 +1,11 @@ +store = $store ? $store : ActionScheduler_Store::instance(); + } + + public function delete_old_actions() { + $lifespan = apply_filters( 'action_scheduler_retention_period', $this->month_in_seconds ); + $cutoff = new DateTime($lifespan.' seconds ago'); + + $actions_to_delete = $this->store->query_actions( array( + 'status' => ActionScheduler_Store::STATUS_COMPLETE, + 'modified' => $cutoff, + 'modified_compare' => '<=', + 'per_page' => apply_filters( 'action_scheduler_cleanup_batch_size', 20 ), + ) ); + + foreach ( $actions_to_delete as $action_id ) { + $this->store->delete_action( $action_id ); + } + } + + public function reset_timeouts() { + $timeout = apply_filters( 'action_scheduler_timeout_period', $this->five_minutes ); + if ( $timeout < 0 ) { + return; + } + $cutoff = new DateTime($timeout.' seconds ago'); + $actions_to_reset = $this->store->query_actions( array( + 'status' => ActionScheduler_Store::STATUS_PENDING, + 'modified' => $cutoff, + 'modified_compare' => '<=', + 'claimed' => TRUE, + 'per_page' => apply_filters( 'action_scheduler_cleanup_batch_size', 20 ), + ) ); + + foreach ( $actions_to_reset as $action_id ) { + $this->store->unclaim_action( $action_id ); + do_action( 'action_scheduler_reset_action', $action_id ); + } + } + + public function mark_failures() { + $timeout = apply_filters( 'action_scheduler_failure_period', $this->five_minutes ); + if ( $timeout < 0 ) { + return; + } + $cutoff = new DateTime($timeout.' seconds ago'); + $actions_to_reset = $this->store->query_actions( array( + 'status' => ActionScheduler_Store::STATUS_RUNNING, + 'modified' => $cutoff, + 'modified_compare' => '<=', + 'per_page' => apply_filters( 'action_scheduler_cleanup_batch_size', 20 ), + ) ); + + foreach ( $actions_to_reset as $action_id ) { + $this->store->mark_failure( $action_id ); + do_action( 'action_scheduler_failed_action', $action_id, $timeout ); + } + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_QueueRunner.php b/includes/libraries/action-scheduler/classes/ActionScheduler_QueueRunner.php new file mode 100644 index 0000000..a125f9c --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_QueueRunner.php @@ -0,0 +1,139 @@ +store = $store ? $store : ActionScheduler_Store::instance(); + } + + /** + * @codeCoverageIgnore + */ + public function init() { + + add_filter( 'cron_schedules', array( self::instance(), 'add_wp_cron_schedule' ) ); + + if ( !wp_next_scheduled(self::WP_CRON_HOOK) ) { + $schedule = apply_filters( 'action_scheduler_run_schedule', self::WP_CRON_SCHEDULE ); + wp_schedule_event( time(), $schedule, self::WP_CRON_HOOK ); + } + + add_action( self::WP_CRON_HOOK, array( self::instance(), 'run' ) ); + } + + public function run() { + @ini_set( 'memory_limit', apply_filters( 'admin_memory_limit', WP_MAX_MEMORY_LIMIT ) ); + @set_time_limit( apply_filters( 'action_scheduler_queue_runner_time_limit', 600 ) ); + do_action( 'action_scheduler_before_process_queue' ); + $this->run_cleanup(); + $count = 0; + if ( $this->store->get_claim_count() < apply_filters( 'action_scheduler_queue_runner_concurrent_batches', 5 ) ) { + $batch_size = apply_filters( 'action_scheduler_queue_runner_batch_size', 25 ); + $this->monitor = new ActionScheduler_FatalErrorMonitor( $this->store ); + $actions_run = $this->do_batch( $batch_size ); + unset( $this->monitor ); + } + + do_action( 'action_scheduler_after_process_queue' ); + return $count; + } + + protected function run_cleanup() { + $cleaner = new ActionScheduler_QueueCleaner( $this->store ); + $cleaner->delete_old_actions(); + $cleaner->reset_timeouts(); + $cleaner->mark_failures(); + } + + protected function do_batch( $size = 100 ) { + $claim = $this->store->stake_claim($size); + $this->monitor->attach($claim); + $processed_actions = 0; + foreach ( $claim->get_actions() as $action_id ) { + // bail if we lost the claim + if ( ! in_array( $action_id, $this->store->find_actions_by_claim_id( $claim->get_id() ) ) ) { + break; + } + $this->process_action( $action_id ); + $processed_actions++; + } + $this->store->release_claim($claim); + $this->monitor->detach(); + $this->clear_caches(); + return $processed_actions; + } + + public function process_action( $action_id ) { + try { + do_action( 'action_scheduler_before_execute', $action_id ); + $action = $this->store->fetch_action( $action_id ); + $this->store->log_execution( $action_id ); + $action->execute(); + do_action( 'action_scheduler_after_execute', $action_id ); + $this->store->mark_complete( $action_id ); + } catch ( Exception $e ) { + $this->store->mark_failure( $action_id ); + do_action( 'action_scheduler_failed_execution', $action_id, $e ); + } + $this->schedule_next_instance( $action ); + } + + protected function schedule_next_instance( ActionScheduler_Action $action ) { + $next = $action->get_schedule()->next( new DateTime() ); + if ( $next ) { + $this->store->save_action( $action, $next ); + } + } + + /** + * Running large batches can eat up memory, as WP adds data to its object cache. + * + * If using a persistent object store, this has the side effect of flushing that + * as well, so this is disabled by default. To enable: + * + * add_filter( 'action_scheduler_queue_runner_flush_cache', '__return_true' ); + * + * @return void + */ + protected function clear_caches() { + if ( ! wp_using_ext_object_cache() || apply_filters( 'action_scheduler_queue_runner_flush_cache', false ) ) { + wp_cache_flush(); + } + } + + public function add_wp_cron_schedule( $schedules ) { + $schedules['every_minute'] = array( + 'interval' => 60, // in seconds + 'display' => __( 'Every minute' ), + ); + + return $schedules; + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_Schedule.php b/includes/libraries/action-scheduler/classes/ActionScheduler_Schedule.php new file mode 100644 index 0000000..b40e4e4 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_Schedule.php @@ -0,0 +1,13 @@ +date = clone($date); + } + + /** + * @param DateTime $after + * + * @return DateTime|null + */ + public function next( DateTime $after = NULL ) { + $after = empty($after) ? new DateTime('@0') : $after; + return ( $after > $this->date ) ? NULL : clone( $this->date ); + } + + /** + * For PHP 5.2 compat, since DateTime objects can't be serialized + * @return array + */ + public function __sleep() { + $this->timestamp = $this->date->format('U'); + return array( + 'timestamp', + ); + } + + public function __wakeup() { + $this->date = new DateTime('@'.$this->timestamp); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_Store.php b/includes/libraries/action-scheduler/classes/ActionScheduler_Store.php new file mode 100644 index 0000000..2d4b973 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_Store.php @@ -0,0 +1,128 @@ +versions[$version_string]) ) { + return FALSE; + } + $this->versions[$version_string] = $initialization_callback; + return TRUE; + } + + public function get_versions() { + return $this->versions; + } + + public function latest_version() { + $keys = array_keys($this->versions); + if ( empty($keys) ) { + return false; + } + uasort( $keys, 'version_compare' ); + return end($keys); + } + + public function latest_version_callback() { + $latest = $this->latest_version(); + if ( empty($latest) || !isset($this->versions[$latest]) ) { + return '__return_null'; + } + return $this->versions[$latest]; + } + + /** + * @return ActionScheduler_Versions + * @codeCoverageIgnore + */ + public static function instance() { + if ( empty(self::$instance) ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * @codeCoverageIgnore + */ + public static function initialize_latest_version() { + $self = self::instance(); + call_user_func($self->latest_version_callback()); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_wpCommentLogger.php b/includes/libraries/action-scheduler/classes/ActionScheduler_wpCommentLogger.php new file mode 100644 index 0000000..b7aa925 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_wpCommentLogger.php @@ -0,0 +1,222 @@ +create_wp_comment( $action_id, $message, $date ); + return $comment_id; + } + + protected function create_wp_comment( $action_id, $message, DateTime $date ) { + $date->setTimezone( ActionScheduler_TimezoneHelper::get_local_timezone() ); + $comment_data = array( + 'comment_post_ID' => $action_id, + 'comment_date' => $date->format('Y-m-d H:i:s'), + 'comment_author' => self::AGENT, + 'comment_content' => $message, + 'comment_agent' => self::AGENT, + 'comment_type' => self::TYPE, + ); + return wp_insert_comment($comment_data); + } + + /** + * @param string $entry_id + * + * @return ActionScheduler_LogEntry + */ + public function get_entry( $entry_id ) { + $comment = $this->get_comment( $entry_id ); + if ( empty($comment) || $comment->comment_type != self::TYPE ) { + return new ActionScheduler_NullLogEntry(); + } + return new ActionScheduler_LogEntry( $comment->comment_post_ID, $comment->comment_content, $comment->comment_type ); + } + + /** + * @param string $action_id + * + * @return ActionScheduler_LogEntry[] + */ + public function get_logs( $action_id ) { + $status = 'all'; + if ( get_post_status($action_id) == 'trash' ) { + $status = 'post-trashed'; + } + $comments = get_comments(array( + 'post_id' => $action_id, + 'orderby' => 'comment_date_gmt', + 'order' => 'ASC', + 'type' => self::TYPE, + 'status' => $status, + )); + $logs = array(); + foreach ( $comments as $c ) { + $entry = $this->get_entry( $c ); + if ( !empty($entry) ) { + $logs[] = $entry; + } + } + return $logs; + } + + protected function get_comment( $comment_id ) { + return get_comment( $comment_id ); + } + + + + /** + * @param WP_Comment_Query $query + * + * @return void + */ + public function filter_comment_queries( $query ) { + foreach ( array('ID', 'parent', 'post_author', 'post_name', 'post_parent', 'type', 'post_type', 'post_id', 'post_ID') as $key ) { + if ( !empty($query->query_vars[$key]) ) { + return; // don't slow down queries that wouldn't include action_log comments anyway + } + } + $query->query_vars['action_log_filter'] = TRUE; + add_filter( 'comments_clauses', array( $this, 'filter_comment_query_clauses' ), 10, 2 ); + } + + /** + * @param array $clauses + * @param WP_Comment_Query $query + * + * @return array + */ + public function filter_comment_query_clauses( $clauses, $query ) { + if ( !empty($query->query_vars['action_log_filter']) ) { + global $wpdb; + $clauses['where'] .= sprintf(" AND {$wpdb->comments}.comment_type != '%s'", self::TYPE); + } + return $clauses; + } + + /** + * Remove action log entries from wp_count_comments() + * + * @param array $stats + * @param int $post_id + * + * @return object + */ + public function filter_comment_count( $stats, $post_id ) { + global $wpdb; + + if ( 0 === $post_id ) { + + $count = wp_cache_get( 'comments-0', 'counts' ); + if ( false !== $count ) { + return $count; + } + + $count = $wpdb->get_results( "SELECT comment_approved, COUNT( * ) AS num_comments FROM {$wpdb->comments} WHERE comment_type NOT IN('order_note','action_log') GROUP BY comment_approved", ARRAY_A ); + + $total = 0; + $stats = array(); + $approved = array( '0' => 'moderated', '1' => 'approved', 'spam' => 'spam', 'trash' => 'trash', 'post-trashed' => 'post-trashed' ); + + foreach ( (array) $count as $row ) { + // Don't count post-trashed toward totals + if ( 'post-trashed' != $row['comment_approved'] && 'trash' != $row['comment_approved'] ) { + $total += $row['num_comments']; + } + if ( isset( $approved[ $row['comment_approved'] ] ) ) { + $stats[ $approved[ $row['comment_approved'] ] ] = $row['num_comments']; + } + } + + $stats['total_comments'] = $total; + foreach ( $approved as $key ) { + if ( empty( $stats[ $key ] ) ) { + $stats[ $key ] = 0; + } + } + + $stats = (object) $stats; + wp_cache_set( 'comments-0', $stats, 'counts' ); + } + + return $stats; + } + + /** + * @codeCoverageIgnore + */ + public function init() { + add_action( 'action_scheduler_before_process_queue', array( $this, 'disable_comment_counting' ), 10, 0 ); + add_action( 'action_scheduler_after_process_queue', array( $this, 'enable_comment_counting' ), 10, 0 ); + add_action( 'action_scheduler_stored_action', array( $this, 'log_stored_action' ), 10, 1 ); + add_action( 'action_scheduler_canceled_action', array( $this, 'log_canceled_action' ), 10, 1 ); + add_action( 'action_scheduler_before_execute', array( $this, 'log_started_action' ), 10, 1 ); + add_action( 'action_scheduler_after_execute', array( $this, 'log_completed_action' ), 10, 1 ); + add_action( 'action_scheduler_failed_execution', array( $this, 'log_failed_action' ), 10, 2 ); + add_action( 'action_scheduler_failed_action', array( $this, 'log_timed_out_action' ), 10, 2 ); + add_action( 'action_scheduler_unexpected_shutdown', array( $this, 'log_unexpected_shutdown' ), 10, 2 ); + add_action( 'action_scheduler_reset_action', array( $this, 'log_reset_action' ), 10, 1 ); + add_action( 'pre_get_comments', array( $this, 'filter_comment_queries' ), 10, 1 ); + add_action( 'wp_count_comments', array( $this, 'filter_comment_count' ), 9, 2 ); // run before WC_Comments::wp_count_comments() + } + + public function disable_comment_counting() { + wp_defer_comment_counting(true); + } + public function enable_comment_counting() { + wp_defer_comment_counting(false); + } + + public function log_stored_action( $action_id ) { + $this->log( $action_id, __('action created', 'action-scheduler') ); + } + + public function log_canceled_action( $action_id ) { + $this->log( $action_id, __('action canceled', 'action-scheduler') ); + } + + public function log_started_action( $action_id ) { + $this->log( $action_id, __('action started', 'action-scheduler') ); + } + + public function log_completed_action( $action_id ) { + $this->log( $action_id, __('action complete', 'action-scheduler') ); + } + + public function log_failed_action( $action_id, Exception $exception ) { + $this->log( $action_id, sprintf(__('action failed: %s', 'action-scheduler'), $exception->getMessage() )); + } + + public function log_timed_out_action( $action_id, $timeout) { + $this->log( $action_id, sprintf( __('action timed out after %s seconds', 'action-scheduler'), $timeout ) ); + } + + public function log_unexpected_shutdown( $action_id, $error ) { + if ( !empty($error) ) { + $this->log( $action_id, sprintf(__('unexpected shutdown: PHP Fatal error %s in %s on line %s', 'action-scheduler'), $error['message'], $error['file'], $error['line'] ) ); + } + } + + public function log_reset_action( $action_id ) { + $this->log( $action_id, __('action reset', 'action_scheduler') ); + } + +} diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore.php b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore.php new file mode 100644 index 0000000..189ca5a --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore.php @@ -0,0 +1,504 @@ +create_post_array( $action, $date ); + $post_id = $this->save_post_array( $post_array ); + $this->save_post_schedule( $post_id, $action->get_schedule() ); + $this->save_action_group( $post_id, $action->get_group() ); + do_action( 'action_scheduler_stored_action', $post_id ); + return $post_id; + } catch ( Exception $e ) { + throw new RuntimeException( sprintf( __('Error saving action: %s', 'action-scheduler'), $e->getMessage() ), 0 ); + } + } + + protected function create_post_array( ActionScheduler_Action $action, DateTime $date = NULL ) { + $post = array( + 'post_type' => self::POST_TYPE, + 'post_title' => $action->get_hook(), + 'post_content' => json_encode($action->get_args()), + 'post_status' => ( $action->is_finished() ? 'publish' : 'pending' ), + 'post_date_gmt' => $this->get_timestamp($action, $date), + 'post_date' => $this->get_local_timestamp($action, $date), + ); + return $post; + } + + protected function get_timestamp( ActionScheduler_Action $action, DateTime $date = NULL ) { + $next = is_null($date) ? $action->get_schedule()->next() : $date; + if ( !$next ) { + throw new InvalidArgumentException(__('Invalid schedule. Cannot save action.', 'action-scheduler')); + } + $next->setTimezone(new DateTimeZone('UTC')); + return $next->format('Y-m-d H:i:s'); + } + + protected function get_local_timestamp( ActionScheduler_Action $action, DateTime $date = NULL ) { + $next = is_null($date) ? $action->get_schedule()->next() : $date; + if ( !$next ) { + throw new InvalidArgumentException(__('Invalid schedule. Cannot save action.', 'action-scheduler')); + } + $next->setTimezone($this->get_local_timezone()); + return $next->format('Y-m-d H:i:s'); + } + + protected function get_local_timezone() { + return ActionScheduler_TimezoneHelper::get_local_timezone(); + } + + protected function save_post_array( $post_array ) { + add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 ); + $post_id = wp_insert_post($post_array); + remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 ); + + if ( is_wp_error($post_id) || empty($post_id) ) { + throw new RuntimeException(__('Unable to save action.', 'action-scheduler')); + } + return $post_id; + } + + public function filter_insert_post_data( $postdata ) { + if ( $postdata['post_type'] == self::POST_TYPE ) { + $postdata['post_author'] = 0; + if ( $postdata['post_status'] == 'future' ) { + $postdata['post_status'] = 'publish'; + } + } + return $postdata; + } + + protected function save_post_schedule( $post_id, $schedule ) { + update_post_meta( $post_id, self::SCHEDULE_META_KEY, $schedule ); + } + + protected function save_action_group( $post_id, $group ) { + if ( empty($group) ) { + wp_set_object_terms( $post_id, array(), self::GROUP_TAXONOMY, FALSE ); + } else { + wp_set_object_terms( $post_id, array($group), self::GROUP_TAXONOMY, FALSE ); + } + } + + public function fetch_action( $action_id ) { + $post = $this->get_post( $action_id ); + if ( empty($post) || $post->post_type != self::POST_TYPE || $post->post_status == 'trash' ) { + return $this->get_null_action(); + } + return $this->make_action_from_post($post); + } + + protected function get_post( $action_id ) { + if ( empty($action_id) ) { + return NULL; + } + return get_post($action_id); + } + + protected function get_null_action() { + return new ActionScheduler_NullAction(); + } + + protected function make_action_from_post( $post ) { + $hook = $post->post_title; + $args = json_decode( $post->post_content, true ); + $schedule = get_post_meta( $post->ID, self::SCHEDULE_META_KEY, true ); + if ( empty($schedule) ) { + $schedule = new ActionScheduler_NullSchedule(); + } + $group = wp_get_object_terms( $post->ID, self::GROUP_TAXONOMY, array('fields' => 'names') ); + $group = empty( $group ) ? '' : reset($group); + if ( $post->post_status == 'pending' ) { + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + } else { + $action = new ActionScheduler_FinishedAction( $hook, $args, $schedule, $group ); + } + return $action; + } + + /** + * @param string $hook + * @param array $params + * + * @return string ID of the next action matching the criteria or NULL if not found + */ + public function find_action( $hook, $params = array() ) { + $params = wp_parse_args( $params, array( + 'args' => NULL, + 'status' => ActionScheduler_Store::STATUS_PENDING, + 'group' => '', + )); + /** @var wpdb $wpdb */ + global $wpdb; + $query = "SELECT p.ID FROM {$wpdb->posts} p"; + $args = array(); + if ( !empty($params['group']) ) { + $query .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID"; + $query .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id"; + $query .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id AND t.slug=%s"; + $args[] = $params['group']; + } + $query .= " WHERE p.post_title=%s"; + $args[] = $hook; + $query .= " AND p.post_type=%s"; + $args[] = self::POST_TYPE; + if ( !is_null($params['args']) ) { + $query .= " AND p.post_content=%s"; + $args[] = json_encode($params['args']); + } + switch ( $params['status'] ) { + case self::STATUS_COMPLETE: + $query .= " AND p.post_status='publish'"; + $order = 'DESC'; // Find the most recent action that matches + break; + case self::STATUS_PENDING: + $query .= " AND p.post_status='pending'"; + $order = 'ASC'; // Find the next action that matches + break; + case self::STATUS_RUNNING: + case self::STATUS_FAILED: + $query .= " AND p.post_status=%s"; + $args[] = $params['status']; + $order = 'DESC'; // Find the most recent action that matches + break; + default: + $order = 'ASC'; + break; + } + $query .= " ORDER BY post_date $order LIMIT 1"; + + $query = $wpdb->prepare( $query, $args ); + + $id = $wpdb->get_var($query); + return $id; + } + + /** + * @param array $query + * @return array The IDs of actions matching the query + */ + public function query_actions( $query = array() ) { + $query = wp_parse_args( $query, array( + 'hook' => '', + 'args' => NULL, + 'date' => NULL, + 'date_compare' => '<=', + 'modified' => NULL, + 'modified_compare' => '<=', + 'group' => '', + 'status' => '', + 'claimed' => NULL, + 'per_page' => 5, + 'offset' => 0, + 'orderby' => 'date', + 'order' => 'ASC', + ) ); + + /** @var wpdb $wpdb */ + global $wpdb; + $sql = "SELECT p.ID FROM {$wpdb->posts} p"; + $sql_params = array(); + if ( !empty($query['group']) ) { + $sql .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID"; + $sql .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id"; + $sql .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id AND t.slug=%s"; + $sql_params[] = $query['group']; + } + $sql .= " WHERE post_type=%s"; + $sql_params[] = self::POST_TYPE; + if ( $query['hook'] ) { + $sql .= " AND p.post_title=%s"; + $sql_params[] = $query['hook']; + } + if ( !is_null($query['args']) ) { + $sql .= " AND p.post_content=%s"; + $sql_params[] = json_encode($query['args']); + } + + switch ( $query['status'] ) { + case self::STATUS_COMPLETE: + $sql .= " AND p.post_status='publish'"; + break; + case self::STATUS_PENDING: + case self::STATUS_RUNNING: + case self::STATUS_FAILED: + $sql .= " AND p.post_status=%s"; + $sql_params[] = $query['status']; + break; + } + + if ( $query['date'] instanceof DateTime ) { + $date = clone( $query['date'] ); + $date->setTimezone( $this->get_local_timezone() ); + $date_string = $date->format('Y-m-d H:i:s'); + $comparator = $this->validate_sql_comparator($query['date_compare']); + $sql .= " AND p.post_date $comparator %s"; + $sql_params[] = $date_string; + } + + if ( $query['modified'] instanceof DateTime ) { + $modified = clone( $query['modified'] ); + $modified->setTimezone( $this->get_local_timezone() ); + $date_string = $modified->format('Y-m-d H:i:s'); + $comparator = $this->validate_sql_comparator($query['modified_compare']); + $sql .= " AND p.post_modified $comparator %s"; + $sql_params[] = $date_string; + } + + if ( $query['claimed'] === TRUE ) { + $sql .= " AND p.post_password != ''"; + } elseif ( $query['claimed'] === FALSE ) { + $sql .= " AND p.post_password = ''"; + } elseif ( !is_null($query['claimed']) ) { + $sql .= " AND p.post_password = %s"; + $sql_params[] = $query['claimed']; + } + + switch ( $query['orderby'] ) { + case 'hook': + $orderby = 'p.title'; + break; + case 'group': + $orderby = 't.name'; + break; + case 'modified': + $orderby = 'p.post_modified'; + break; + case 'date': + default: + $orderby = 'p.post_date'; + break; + } + if ( strtoupper($query['order']) == 'ASC' ) { + $order = 'ASC'; + } else { + $order = 'DESC'; + } + $sql .= " ORDER BY $orderby $order"; + if ( $query['per_page'] > 0 ) { + $sql .= " LIMIT %d, %d"; + $sql_params[] = $query['offset']; + $sql_params[] = $query['per_page']; + } + + $sql = $wpdb->prepare( $sql, $sql_params ); + + $id = $wpdb->get_col($sql); + return $id; + } + + private function validate_sql_comparator( $comp ) { + if ( in_array($comp, array('!=', '>', '>=', '<', '<=', '=')) ) { + return $comp; + } + return '='; + } + + /** + * @param string $action_id + * + * @throws InvalidArgumentException + * @return void + */ + public function cancel_action( $action_id ) { + $post = get_post($action_id); + if ( empty($post) || ($post->post_type != self::POST_TYPE) ) { + throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'action-scheduler'), $action_id)); + } + do_action( 'action_scheduler_canceled_action', $action_id ); + wp_trash_post($action_id); + } + + public function delete_action( $action_id ) { + $post = get_post($action_id); + if ( empty($post) || ($post->post_type != self::POST_TYPE) ) { + throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'action-scheduler'), $action_id)); + } + do_action( 'action_scheduler_deleted_action', $action_id ); + wp_delete_post($action_id, TRUE); + } + + /** + * @param string $action_id + * + * @throws InvalidArgumentException + * @return DateTime The date the action is schedule to run, or the date that it ran. + */ + public function get_date( $action_id ) { + $post = get_post($action_id); + if ( empty($post) || ($post->post_type != self::POST_TYPE) ) { + throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'action-scheduler'), $action_id)); + } + if ( $post->post_status == 'publish' ) { + return new DateTime($post->post_modified, ActionScheduler_TimezoneHelper::get_local_timezone()); + } else { + return new DateTime($post->post_date, ActionScheduler_TimezoneHelper::get_local_timezone()); + } + } + + + /** + * @param int $max_actions + * @param DateTime $before_date Jobs must be schedule before this date. Defaults to now. + * + * @return ActionScheduler_ActionClaim + */ + public function stake_claim( $max_actions = 10, DateTime $before_date = NULL ){ + $claim_id = $this->generate_claim_id(); + $this->claim_actions( $claim_id, $max_actions, $before_date ); + $action_ids = $this->find_actions_by_claim_id( $claim_id ); + return new ActionScheduler_ActionClaim( $claim_id, $action_ids ); + } + + /** + * @return int + */ + public function get_claim_count(){ + global $wpdb; + + $sql = "SELECT COUNT(DISTINCT post_password) FROM {$wpdb->posts} WHERE post_password != '' AND post_type = %s AND post_status IN ('in-progress','pending')"; + $sql = $wpdb->prepare( $sql, array( self::POST_TYPE ) ); + + return $wpdb->get_var( $sql ); + } + + protected function generate_claim_id() { + $claim_id = md5(microtime(true) . rand(0,1000)); + return substr($claim_id, 0, 20); // to fit in db field with 20 char limit + } + + /** + * @param string $claim_id + * @param int $limit + * @param DateTime $before_date + * @return int The number of actions that were claimed + * @throws RuntimeException + */ + protected function claim_actions( $claim_id, $limit, DateTime $before_date = NULL ) { + /** @var wpdb $wpdb */ + global $wpdb; + $date = is_null($before_date) ? new DateTime() : clone( $before_date ); + $date->setTimezone( $this->get_local_timezone() ); // using post_modified to take advantage of indexes + // can't use $wpdb->update() because of the <= condition + $sql = "UPDATE {$wpdb->posts} SET post_password = %s, post_modified_gmt = %s, post_modified = %s WHERE post_type = %s AND post_status = %s AND post_password = '' AND post_date <= %s ORDER BY menu_order ASC, post_date ASC LIMIT %d"; + $sql = $wpdb->prepare( $sql, array( $claim_id, current_time('mysql', true), current_time('mysql'), self::POST_TYPE, 'pending', $date->format('Y-m-d H:i:s'), $limit ) ); + $rows_affected = $wpdb->query($sql); + if ( $rows_affected === false ) { + throw new RuntimeException(__('Unable to claim actions. Database error.', 'action-scheduler')); + } + return (int)$rows_affected; + } + + /** + * @param string $claim_id + * @return array + */ + public function find_actions_by_claim_id( $claim_id ) { + /** @var wpdb $wpdb */ + global $wpdb; + $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s"; + $sql = $wpdb->prepare( $sql, array( self::POST_TYPE, $claim_id ) ); + $action_ids = $wpdb->get_col( $sql ); + return $action_ids; + } + + public function release_claim( ActionScheduler_ActionClaim $claim ) { + $action_ids = $this->find_actions_by_claim_id( $claim->get_id() ); + if ( empty($action_ids) ) { + return; // nothing to do + } + $action_id_string = implode(',', array_map('intval', $action_ids)); + /** @var wpdb $wpdb */ + global $wpdb; + $sql = "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID IN ($action_id_string) AND post_password = %s"; + $sql = $wpdb->prepare( $sql, array( $claim->get_id() ) ); + $result = $wpdb->query($sql); + if ( $result === false ) { + throw new RuntimeException( sprintf( __('Unable to unlock claim %s. Database error.', 'action-scheduler'), $claim->get_id() ) ); + } + } + + /** + * @param string $action_id + * + * @return void + */ + public function unclaim_action( $action_id ) { + /** @var wpdb $wpdb */ + global $wpdb; + $sql = "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID = %d AND post_type = %s"; + $sql = $wpdb->prepare( $sql, $action_id, self::POST_TYPE ); + $result = $wpdb->query($sql); + if ( $result === false ) { + throw new RuntimeException( sprintf( __('Unable to unlock claim on action %s. Database error.', 'action-scheduler'), $action_id ) ); + } + } + + public function mark_failure( $action_id ) { + /** @var wpdb $wpdb */ + global $wpdb; + $sql = "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type = %s"; + $sql = $wpdb->prepare( $sql, self::STATUS_FAILED, $action_id, self::POST_TYPE ); + $result = $wpdb->query($sql); + if ( $result === false ) { + throw new RuntimeException( sprintf( __('Unable to mark failure on action %s. Database error.', 'action-scheduler'), $action_id ) ); + } + } + + /** + * @param string $action_id + * + * @return void + */ + public function log_execution( $action_id ) { + /** @var wpdb $wpdb */ + global $wpdb; + + $sql = "UPDATE {$wpdb->posts} SET menu_order = menu_order+1, post_status=%s, post_modified_gmt = %s, post_modified = %s WHERE ID = %d AND post_type = %s"; + $sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time('mysql', true), current_time('mysql'), $action_id, self::POST_TYPE ); + $wpdb->query($sql); + } + + + public function mark_complete( $action_id ) { + $post = get_post($action_id); + if ( empty($post) || ($post->post_type != self::POST_TYPE) ) { + throw new InvalidArgumentException(sprintf(__('Unidentified action %s', 'action-scheduler'), $action_id)); + } + add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 ); + $result = wp_update_post(array( + 'ID' => $action_id, + 'post_status' => 'publish', + ), TRUE); + remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 ); + if ( is_wp_error($result) ) { + throw new RuntimeException($result->get_error_message()); + } + } + + /** + * @codeCoverageIgnore + */ + public function init() { + $post_type_registrar = new ActionScheduler_wpPostStore_PostTypeRegistrar(); + $post_type_registrar->register(); + + $post_status_registrar = new ActionScheduler_wpPostStore_PostStatusRegistrar(); + $post_status_registrar->register(); + + $taxonomy_registrar = new ActionScheduler_wpPostStore_TaxonomyRegistrar(); + $taxonomy_registrar->register(); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_PostStatusRegistrar.php b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_PostStatusRegistrar.php new file mode 100644 index 0000000..a85b154 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_PostStatusRegistrar.php @@ -0,0 +1,57 @@ +post_status_args(), $this->post_status_running_labels() ) ); + register_post_status( ActionScheduler_Store::STATUS_FAILED, array_merge( $this->post_status_args(), $this->post_status_failed_labels() ) ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_status_args() { + $args = array( + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + ); + + return apply_filters( 'action_scheduler_post_status_args', $args ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_status_failed_labels() { + $labels = array( + 'label' => _x( 'Failed', 'post' ), + 'label_count' => _n_noop( 'Failed (%s)', 'Failed (%s)' ), + ); + + return apply_filters( 'action_scheduler_post_status_failed_labels', $labels ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_status_running_labels() { + $labels = array( + 'label' => _x( 'In-Progress', 'post' ), + 'label_count' => _n_noop( 'In-Progress (%s)', 'In-Progress (%s)' ), + ); + + return apply_filters( 'action_scheduler_post_status_running_labels', $labels ); + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_PostTypeRegistrar.php b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_PostTypeRegistrar.php new file mode 100644 index 0000000..8c63bd0 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_PostTypeRegistrar.php @@ -0,0 +1,50 @@ +post_type_args() ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_type_args() { + $args = array( + 'label' => __( 'Scheduled Actions', 'action-scheduler' ), + 'description' => __( 'Scheduled actions are hooks triggered on a cetain date and time.', 'action-scheduler' ), + 'public' => false, + 'map_meta_cap' => true, + 'hierarchical' => false, + 'supports' => array('title', 'editor','comments'), + 'rewrite' => false, + 'query_var' => false, + 'can_export' => true, + 'ep_mask' => EP_NONE, + 'labels' => array( + 'name' => __( 'Scheduled Actions', 'action-scheduler' ), + 'singular_name' => __( 'Scheduled Action', 'action-scheduler' ), + 'menu_name' => _x( 'Scheduled Actions', 'Admin menu name', 'action-scheduler' ), + 'add_new' => __( 'Add', 'action-scheduler' ), + 'add_new_item' => __( 'Add New Scheduled Action', 'action-scheduler' ), + 'edit' => __( 'Edit', 'action-scheduler' ), + 'edit_item' => __( 'Edit Scheduled Action', 'action-scheduler' ), + 'new_item' => __( 'New Scheduled Action', 'action-scheduler' ), + 'view' => __( 'View Action', 'action-scheduler' ), + 'view_item' => __( 'View Action', 'action-scheduler' ), + 'search_items' => __( 'Search Scheduled Actions', 'action-scheduler' ), + 'not_found' => __( 'No actions found', 'action-scheduler' ), + 'not_found_in_trash' => __( 'No actions found in trash', 'action-scheduler' ), + ), + ); + + $args = apply_filters('action_scheduler_post_type_args', $args); + return $args; + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_TaxonomyRegistrar.php b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_TaxonomyRegistrar.php new file mode 100644 index 0000000..026d625 --- /dev/null +++ b/includes/libraries/action-scheduler/classes/ActionScheduler_wpPostStore_TaxonomyRegistrar.php @@ -0,0 +1,26 @@ +taxonomy_args() ); + } + + protected function taxonomy_args() { + $args = array( + 'label' => __('Action Group', 'action-scheduler'), + 'public' => false, + 'hierarchical' => false, + 'show_admin_column' => true, + 'query_var' => false, + 'rewrite' => false, + ); + + $args = apply_filters('action_scheduler_taxonomy_args', $args); + return $args; + } +} + \ No newline at end of file diff --git a/includes/libraries/action-scheduler/functions.php b/includes/libraries/action-scheduler/functions.php new file mode 100644 index 0000000..38f5132 --- /dev/null +++ b/includes/libraries/action-scheduler/functions.php @@ -0,0 +1,160 @@ +single( $hook, $args, $timestamp, $group ); +} + +/** + * Schedule a recurring action + * + * @param int $timestamp When the first instance of the job will run + * @param int $interval_in_seconds How long to wait between runs + * @param string $hook The hook to trigger + * @param array $args Arguments to pass when the hook triggers + * @param string $group The group to assign this job to + * + * @return string The job ID + */ +function wc_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '' ) { + return ActionScheduler::factory()->recurring( $hook, $args, $timestamp, $interval_in_seconds, $group ); +} + +/** + * Schedule an action that recurs on a cron-like schedule. + * + * @param int $timestamp The schedule will start on or after this time + * @param string $schedule A cron-link schedule string + * @see http://en.wikipedia.org/wiki/Cron + * * * * * * * + * ┬ ┬ ┬ ┬ ┬ ┬ + * | | | | | | + * | | | | | + year [optional] + * | | | | +----- day of week (0 - 7) (Sunday=0 or 7) + * | | | +---------- month (1 - 12) + * | | +--------------- day of month (1 - 31) + * | +-------------------- hour (0 - 23) + * +------------------------- min (0 - 59) + * @param string $hook The hook to trigger + * @param array $args Arguments to pass when the hook triggers + * @param string $group The group to assign this job to + * + * @return string The job ID + */ +function wc_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '' ) { + return ActionScheduler::factory()->cron( $hook, $args, $timestamp, $schedule, $group ); +} + +/** + * Cancel the next occurrence of a job. + * + * @param string $hook The hook that the job will trigger + * @param array $args Args that would have been passed to the job + * @param string $group + * + * @return void + */ +function wc_unschedule_action( $hook, $args = array(), $group = '' ) { + $params = array(); + if ( is_array($args) ) { + $params['args'] = $args; + } + if ( !empty($group) ) { + $params['group'] = $group; + } + $job_id = ActionScheduler::store()->find_action( $hook, $params ); + if ( empty($job_id) ) { + return; + } + + ActionScheduler::store()->cancel_action( $job_id ); +} + +/** + * @param string $hook + * @param array $args + * @param string $group + * + * @return int|bool The timestamp for the next occurrence, or false if nothing was found + */ +function wc_next_scheduled_action( $hook, $args = NULL, $group = '' ) { + $params = array(); + if ( is_array($args) ) { + $params['args'] = $args; + } + if ( !empty($group) ) { + $params['group'] = $group; + } + $job_id = ActionScheduler::store()->find_action( $hook, $params ); + if ( empty($job_id) ) { + return false; + } + $job = ActionScheduler::store()->fetch_action( $job_id ); + $next = $job->get_schedule()->next(); + if ( $next ) { + return (int)($next->format('U')); + } + return false; +} + +/** + * Find scheduled actions + * + * @param array $args Possible arguments, with their default values: + * 'hook' => '' - the name of the action that will be triggered + * 'args' => NULL - the args array that will be passed with the action + * 'date' => NULL - the scheduled date of the action. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). + * 'date_compare' => '<=' - operator for testing "date". accepted values are '!=', '>', '>=', '<', '<=', '=' + * 'modified' => NULL - the date the action was last updated. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). + * 'modified_compare' => '<=' - operator for testing "modified". accepted values are '!=', '>', '>=', '<', '<=', '=' + * 'group' => '' - the group the action belongs to + * 'status' => '' - ActionScheduler_Store::STATUS_COMPLETE or ActionScheduler_Store::STATUS_PENDING + * 'claimed' => NULL - TRUE to find claimed actions, FALSE to find unclaimed actions, a string to find a specific claim ID + * 'per_page' => 5 - Number of results to return + * 'offset' => 0 + * 'orderby' => 'date' - accepted values are 'hook', 'group', 'modified', or 'date' + * 'order' => 'ASC' + * + * @param string $return_format OBJECT, ARRAY_A, or ids + * + * @return array + */ +function wc_get_scheduled_actions( $args = array(), $return_format = OBJECT ) { + $store = ActionScheduler::store(); + foreach ( array('date', 'modified') as $key ) { + if ( isset($args[$key]) ) { + $args[$key] = ActionScheduler::get_datetime_object($args[$key]); + } + } + $ids = $store->query_actions( $args ); + + if ( $return_format == 'ids' || $return_format == 'int' ) { + return $ids; + } + + $actions = array(); + foreach ( $ids as $action_id ) { + $actions[$action_id] = $store->fetch_action( $action_id ); + } + + if ( $return_format == ARRAY_A ) { + foreach ( $actions as $action_id => $action_object ) { + $actions[$action_id] = get_object_vars($action_object); + } + } + + return $actions; +} \ No newline at end of file diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression.php new file mode 100755 index 0000000..43443ff --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression.php @@ -0,0 +1,318 @@ + + * @link http://en.wikipedia.org/wiki/Cron + */ +class CronExpression +{ + const MINUTE = 0; + const HOUR = 1; + const DAY = 2; + const MONTH = 3; + const WEEKDAY = 4; + const YEAR = 5; + + /** + * @var array CRON expression parts + */ + private $cronParts; + + /** + * @var CronExpression_FieldFactory CRON field factory + */ + private $fieldFactory; + + /** + * @var array Order in which to test of cron parts + */ + private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE); + + /** + * Factory method to create a new CronExpression. + * + * @param string $expression The CRON expression to create. There are + * several special predefined values which can be used to substitute the + * CRON expression: + * + * @yearly, @annually) - Run once a year, midnight, Jan. 1 - 0 0 1 1 * + * @monthly - Run once a month, midnight, first of month - 0 0 1 * * + * @weekly - Run once a week, midnight on Sun - 0 0 * * 0 + * @daily - Run once a day, midnight - 0 0 * * * + * @hourly - Run once an hour, first minute - 0 * * * * + * +*@param CronExpression_FieldFactory $fieldFactory (optional) Field factory to use + * + * @return CronExpression + */ + public static function factory($expression, CronExpression_FieldFactory $fieldFactory = null) + { + $mappings = array( + '@yearly' => '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@hourly' => '0 * * * *' + ); + + if (isset($mappings[$expression])) { + $expression = $mappings[$expression]; + } + + return new self($expression, $fieldFactory ? $fieldFactory : new CronExpression_FieldFactory()); + } + + /** + * Parse a CRON expression + * + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param CronExpression_FieldFactory $fieldFactory Factory to create cron fields + */ + public function __construct($expression, CronExpression_FieldFactory $fieldFactory) + { + $this->fieldFactory = $fieldFactory; + $this->setExpression($expression); + } + + /** + * Set or change the CRON expression + * + * @param string $value CRON expression (e.g. 8 * * * *) + * + * @return CronExpression + * @throws InvalidArgumentException if not a valid CRON expression + */ + public function setExpression($value) + { + $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + if (count($this->cronParts) < 5) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + foreach ($this->cronParts as $position => $part) { + $this->setPart($position, $part); + } + + return $this; + } + + /** + * Set part of the CRON expression + * + * @param int $position The position of the CRON expression to set + * @param string $value The value to set + * + * @return CronExpression + * @throws InvalidArgumentException if the value is not valid for the part + */ + public function setPart($position, $value) + { + if (!$this->fieldFactory->getField($position)->validate($value)) { + throw new InvalidArgumentException( + 'Invalid CRON field value ' . $value . ' as position ' . $position + ); + } + + $this->cronParts[$position] = $value; + + return $this; + } + + /** + * Get a next run date relative to the current date or a specific date + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param int $nth (optional) Number of matches to skip before returning a + * matching next run date. 0, the default, will return the current + * date and time if the next run date falls on the current date and + * time. Setting this value to 1 will skip the first match and go to + * the second match. Setting this value to 2 will skip the first 2 + * matches and so on. + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return DateTime + * @throws RuntimeException on too many iterations + */ + public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate); + } + + /** + * Get a previous run date relative to the current date or a specific date + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param int $nth (optional) Number of matches to skip before returning + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return DateTime + * @throws RuntimeException on too many iterations + * @see CronExpression::getNextRunDate + */ + public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate); + } + + /** + * Get multiple run dates starting at the current date or a specific date + * + * @param int $total Set the total number of dates to calculate + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param bool $invert (optional) Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return array Returns an array of run dates + */ + public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false) + { + $matches = array(); + for ($i = 0; $i < max(0, $total); $i++) { + $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate); + } + + return $matches; + } + + /** + * Get all or part of the CRON expression + * + * @param string $part (optional) Specify the part to retrieve or NULL to + * get the full cron schedule string. + * + * @return string|null Returns the CRON expression, a part of the + * CRON expression, or NULL if the part was specified but not found + */ + public function getExpression($part = null) + { + if (null === $part) { + return implode(' ', $this->cronParts); + } elseif (array_key_exists($part, $this->cronParts)) { + return $this->cronParts[$part]; + } + + return null; + } + + /** + * Helper method to output the full expression. + * + * @return string Full CRON expression + */ + public function __toString() + { + return $this->getExpression(); + } + + /** + * Determine if the cron is due to run based on the current date or a + * specific date. This method assumes that the current number of + * seconds are irrelevant, and should be called once per minute. + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * + * @return bool Returns TRUE if the cron is due to run or FALSE if not + */ + public function isDue($currentTime = 'now') + { + if ('now' === $currentTime) { + $currentDate = date('Y-m-d H:i'); + $currentTime = strtotime($currentDate); + } elseif ($currentTime instanceof DateTime) { + $currentDate = $currentTime->format('Y-m-d H:i'); + $currentTime = strtotime($currentDate); + } else { + $currentTime = new DateTime($currentTime); + $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0); + $currentDate = $currentTime->format('Y-m-d H:i'); + $currentTime = (int)($currentTime->format('U')); + } + + return $this->getNextRunDate($currentDate, 0, true)->format('U') == $currentTime; + } + + /** + * Get the next or previous run date of the expression relative to a date + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param int $nth (optional) Number of matches to skip before returning + * @param bool $invert (optional) Set to TRUE to go backwards in time + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return DateTime + * @throws RuntimeException on too many iterations + */ + protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false) + { + if ($currentTime instanceof DateTime) { + $currentDate = $currentTime; + } else { + $currentDate = new DateTime($currentTime ? $currentTime : 'now'); + $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get())); + } + + $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); + $nextRun = clone $currentDate; + $nth = (int) $nth; + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < 1000; $i++) { + + foreach (self::$order as $position) { + $part = $this->getExpression($position); + if (null === $part) { + continue; + } + + $satisfied = false; + // Get the field object used to validate this part + $field = $this->fieldFactory->getField($position); + // Check if this is singular or a list + if (strpos($part, ',') === false) { + $satisfied = $field->isSatisfiedBy($nextRun, $part); + } else { + foreach (array_map('trim', explode(',', $part)) as $listPart) { + if ($field->isSatisfiedBy($nextRun, $listPart)) { + $satisfied = true; + break; + } + } + } + + // If the field is not satisfied, then start over + if (!$satisfied) { + $field->increment($nextRun, $invert); + continue 2; + } + } + + // Skip this match if needed + if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { + $this->fieldFactory->getField(0)->increment($nextRun, $invert); + continue; + } + + return $nextRun; + } + + // @codeCoverageIgnoreStart + throw new RuntimeException('Impossible CRON expression'); + // @codeCoverageIgnoreEnd + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_AbstractField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_AbstractField.php new file mode 100755 index 0000000..f8d5c00 --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_AbstractField.php @@ -0,0 +1,100 @@ + + */ +abstract class CronExpression_AbstractField implements CronExpression_FieldInterface +{ + /** + * Check to see if a field is satisfied by a value + * + * @param string $dateValue Date value to check + * @param string $value Value to test + * + * @return bool + */ + public function isSatisfied($dateValue, $value) + { + if ($this->isIncrementsOfRanges($value)) { + return $this->isInIncrementsOfRanges($dateValue, $value); + } elseif ($this->isRange($value)) { + return $this->isInRange($dateValue, $value); + } + + return $value == '*' || $dateValue == $value; + } + + /** + * Check if a value is a range + * + * @param string $value Value to test + * + * @return bool + */ + public function isRange($value) + { + return strpos($value, '-') !== false; + } + + /** + * Check if a value is an increments of ranges + * + * @param string $value Value to test + * + * @return bool + */ + public function isIncrementsOfRanges($value) + { + return strpos($value, '/') !== false; + } + + /** + * Test if a value is within a range + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInRange($dateValue, $value) + { + $parts = array_map('trim', explode('-', $value, 2)); + + return $dateValue >= $parts[0] && $dateValue <= $parts[1]; + } + + /** + * Test if a value is within an increments of ranges (offset[-to]/step size) + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInIncrementsOfRanges($dateValue, $value) + { + $parts = array_map('trim', explode('/', $value, 2)); + $stepSize = isset($parts[1]) ? $parts[1] : 0; + if ($parts[0] == '*' || $parts[0] === '0') { + return (int) $dateValue % $stepSize == 0; + } + + $range = explode('-', $parts[0], 2); + $offset = $range[0]; + $to = isset($range[1]) ? $range[1] : $dateValue; + // Ensure that the date value is within the range + if ($dateValue < $offset || $dateValue > $to) { + return false; + } + + for ($i = $offset; $i <= $to; $i+= $stepSize) { + if ($i == $dateValue) { + return true; + } + } + + return false; + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_DayOfMonthField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_DayOfMonthField.php new file mode 100755 index 0000000..40c1d6c --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_DayOfMonthField.php @@ -0,0 +1,110 @@ + + */ +class CronExpression_DayOfMonthField extends CronExpression_AbstractField +{ + /** + * Get the nearest day of the week for a given day in a month + * + * @param int $currentYear Current year + * @param int $currentMonth Current month + * @param int $targetDay Target day of the month + * + * @return DateTime Returns the nearest date + */ + private static function getNearestWeekday($currentYear, $currentMonth, $targetDay) + { + $tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT); + $target = new DateTime("$currentYear-$currentMonth-$tday"); + $currentWeekday = (int) $target->format('N'); + + if ($currentWeekday < 6) { + return $target; + } + + $lastDayOfMonth = $target->format('t'); + + foreach (array(-1, 1, -2, 2) as $i) { + $adjusted = $targetDay + $i; + if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { + $target->setDate($currentYear, $currentMonth, $adjusted); + if ($target->format('N') < 6 && $target->format('m') == $currentMonth) { + return $target; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + // ? states that the field value is to be skipped + if ($value == '?') { + return true; + } + + $fieldValue = $date->format('d'); + + // Check to see if this is the last day of the month + if ($value == 'L') { + return $fieldValue == $date->format('t'); + } + + // Check to see if this is the nearest weekday to a particular value + if (strpos($value, 'W')) { + // Parse the target day + $targetDay = substr($value, 0, strpos($value, 'W')); + // Find out if the current day is the nearest day of the week + return $date->format('j') == self::getNearestWeekday( + $date->format('Y'), + $date->format('m'), + $targetDay + )->format('j'); + } + + return $this->isSatisfied($date->format('d'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('previous day'); + $date->setTime(23, 59); + } else { + $date->modify('next day'); + $date->setTime(0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-\?LW0-9A-Za-z]+/', $value); + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_DayOfWeekField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_DayOfWeekField.php new file mode 100755 index 0000000..e9f68a7 --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_DayOfWeekField.php @@ -0,0 +1,124 @@ + + */ +class CronExpression_DayOfWeekField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + if ($value == '?') { + return true; + } + + // Convert text day of the week values to integers + $value = str_ireplace( + array('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'), + range(0, 6), + $value + ); + + $currentYear = $date->format('Y'); + $currentMonth = $date->format('m'); + $lastDayOfMonth = $date->format('t'); + + // Find out if this is the last specific weekday of the month + if (strpos($value, 'L')) { + $weekday = str_replace('7', '0', substr($value, 0, strpos($value, 'L'))); + $tdate = clone $date; + $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); + while ($tdate->format('w') != $weekday) { + $tdate->setDate($currentYear, $currentMonth, --$lastDayOfMonth); + } + + return $date->format('j') == $lastDayOfMonth; + } + + // Handle # hash tokens + if (strpos($value, '#')) { + list($weekday, $nth) = explode('#', $value); + // Validate the hash fields + if ($weekday < 1 || $weekday > 5) { + throw new InvalidArgumentException("Weekday must be a value between 1 and 5. {$weekday} given"); + } + if ($nth > 5) { + throw new InvalidArgumentException('There are never more than 5 of a given weekday in a month'); + } + // The current weekday must match the targeted weekday to proceed + if ($date->format('N') != $weekday) { + return false; + } + + $tdate = clone $date; + $tdate->setDate($currentYear, $currentMonth, 1); + $dayCount = 0; + $currentDay = 1; + while ($currentDay < $lastDayOfMonth + 1) { + if ($tdate->format('N') == $weekday) { + if (++$dayCount >= $nth) { + break; + } + } + $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + } + + return $date->format('j') == $currentDay; + } + + // Handle day of the week values + if (strpos($value, '-')) { + $parts = explode('-', $value); + if ($parts[0] == '7') { + $parts[0] = '0'; + } elseif ($parts[1] == '0') { + $parts[1] = '7'; + } + $value = implode('-', $parts); + } + + // Test to see which Sunday to use -- 0 == 7 == Sunday + $format = in_array(7, str_split($value)) ? 'N' : 'w'; + $fieldValue = $date->format($format); + + return $this->isSatisfied($fieldValue, $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 day'); + $date->setTime(23, 59, 0); + } else { + $date->modify('+1 day'); + $date->setTime(0, 0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9A-Z]+/', $value); + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_FieldFactory.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_FieldFactory.php new file mode 100755 index 0000000..556ba1a --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_FieldFactory.php @@ -0,0 +1,55 @@ + + * @link http://en.wikipedia.org/wiki/Cron + */ +class CronExpression_FieldFactory +{ + /** + * @var array Cache of instantiated fields + */ + private $fields = array(); + + /** + * Get an instance of a field object for a cron expression position + * + * @param int $position CRON expression position value to retrieve + * + * @return CronExpression_FieldInterface + * @throws InvalidArgumentException if a position is not valid + */ + public function getField($position) + { + if (!isset($this->fields[$position])) { + switch ($position) { + case 0: + $this->fields[$position] = new CronExpression_MinutesField(); + break; + case 1: + $this->fields[$position] = new CronExpression_HoursField(); + break; + case 2: + $this->fields[$position] = new CronExpression_DayOfMonthField(); + break; + case 3: + $this->fields[$position] = new CronExpression_MonthField(); + break; + case 4: + $this->fields[$position] = new CronExpression_DayOfWeekField(); + break; + case 5: + $this->fields[$position] = new CronExpression_YearField(); + break; + default: + throw new InvalidArgumentException( + $position . ' is not a valid position' + ); + } + } + + return $this->fields[$position]; + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_FieldInterface.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_FieldInterface.php new file mode 100755 index 0000000..5d5109b --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_FieldInterface.php @@ -0,0 +1,39 @@ + + */ +interface CronExpression_FieldInterface +{ + /** + * Check if the respective value of a DateTime field satisfies a CRON exp + * + * @param DateTime $date DateTime object to check + * @param string $value CRON expression to test against + * + * @return bool Returns TRUE if satisfied, FALSE otherwise + */ + public function isSatisfiedBy(DateTime $date, $value); + + /** + * When a CRON expression is not satisfied, this method is used to increment + * or decrement a DateTime object by the unit of the cron field + * + * @param DateTime $date DateTime object to change + * @param bool $invert (optional) Set to TRUE to decrement + * + * @return CronExpression_FieldInterface + */ + public function increment(DateTime $date, $invert = false); + + /** + * Validates a CRON expression for a given field + * + * @param string $value CRON expression value to validate + * + * @return bool Returns TRUE if valid, FALSE otherwise + */ + public function validate($value); +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_HoursField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_HoursField.php new file mode 100755 index 0000000..088ca73 --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_HoursField.php @@ -0,0 +1,47 @@ + + */ +class CronExpression_HoursField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + return $this->isSatisfied($date->format('H'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + // Change timezone to UTC temporarily. This will + // allow us to go back or forwards and hour even + // if DST will be changed between the hours. + $timezone = $date->getTimezone(); + $date->setTimezone(new DateTimeZone('UTC')); + if ($invert) { + $date->modify('-1 hour'); + $date->setTime($date->format('H'), 59); + } else { + $date->modify('+1 hour'); + $date->setTime($date->format('H'), 0); + } + $date->setTimezone($timezone); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9]+/', $value); + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_MinutesField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_MinutesField.php new file mode 100755 index 0000000..436acf2 --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_MinutesField.php @@ -0,0 +1,39 @@ + + */ +class CronExpression_MinutesField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + return $this->isSatisfied($date->format('i'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 minute'); + } else { + $date->modify('+1 minute'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9]+/', $value); + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_MonthField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_MonthField.php new file mode 100755 index 0000000..d3deb12 --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_MonthField.php @@ -0,0 +1,55 @@ + + */ +class CronExpression_MonthField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + // Convert text month values to integers + $value = str_ireplace( + array( + 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', + 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC' + ), + range(1, 12), + $value + ); + + return $this->isSatisfied($date->format('m'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + // $date->modify('last day of previous month'); // remove for php 5.2 compat + $date->modify('previous month'); + $date->modify($date->format('Y-m-t')); + $date->setTime(23, 59); + } else { + //$date->modify('first day of next month'); // remove for php 5.2 compat + $date->modify('next month'); + $date->modify($date->format('Y-m-01')); + $date->setTime(0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9A-Z]+/', $value); + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_YearField.php b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_YearField.php new file mode 100755 index 0000000..f11562e --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/CronExpression_YearField.php @@ -0,0 +1,43 @@ + + */ +class CronExpression_YearField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + return $this->isSatisfied($date->format('Y'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 year'); + $date->setDate($date->format('Y'), 12, 31); + $date->setTime(23, 59, 0); + } else { + $date->modify('+1 year'); + $date->setDate($date->format('Y'), 1, 1); + $date->setTime(0, 0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9]+/', $value); + } +} diff --git a/includes/libraries/action-scheduler/lib/cron-expression/LICENSE b/includes/libraries/action-scheduler/lib/cron-expression/LICENSE new file mode 100755 index 0000000..c6d88ac --- /dev/null +++ b/includes/libraries/action-scheduler/lib/cron-expression/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Michael Dowling and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/includes/libraries/tlc-transients/LICENSE b/includes/libraries/tlc-transients/LICENSE new file mode 100644 index 0000000..ecbc059 --- /dev/null +++ b/includes/libraries/tlc-transients/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. \ No newline at end of file diff --git a/includes/libraries/tlc-transients/class-tlc-transient-update-server.php b/includes/libraries/tlc-transients/class-tlc-transient-update-server.php new file mode 100644 index 0000000..a6bc93a --- /dev/null +++ b/includes/libraries/tlc-transients/class-tlc-transient-update-server.php @@ -0,0 +1,25 @@ +expires_in( $update[2] ) + ->extend_on_fail( $update[5] ) + ->updates_with( $update[3], (array) $update[4] ) + ->set_lock( $update[0] ) + ->fetch_and_cache(); + } + exit(); + } + } +} \ No newline at end of file diff --git a/includes/libraries/tlc-transients/class-tlc-transient.php b/includes/libraries/tlc-transients/class-tlc-transient.php new file mode 100644 index 0000000..efba218 --- /dev/null +++ b/includes/libraries/tlc-transients/class-tlc-transient.php @@ -0,0 +1,146 @@ +raw_key = $key; + $this->key = md5( $key ); + } + + public function get() { + $data = $this->raw_get(); + if ( false === $data ) { + // Hard expiration + if ( $this->force_background_updates ) { + // In this mode, we never do a just-in-time update + // We return false, and schedule a fetch on shutdown + $this->schedule_background_fetch(); + return false; + } + else { + // Bill O'Reilly mode: "We'll do it live!" + return $this->fetch_and_cache(); + } + } + else { + // Soft expiration + if ( $data[0] !== 0 && $data[0] < time() ) + $this->schedule_background_fetch(); + return $data[1]; + } + } + + private function raw_get() { + return get_transient( 'tlc__' . $this->key ); + } + + private function schedule_background_fetch() { + if ( ! $this->has_update_lock() ) { + set_transient( 'tlc_up__' . $this->key, array( $this->new_update_lock(), $this->raw_key, $this->expiration, $this->callback, $this->params, $this->extend_on_fail ), 300 ); + add_action( 'shutdown', array( $this, 'spawn_server' ) ); + } + return $this; + } + + private function has_update_lock() { + return (bool) $this->get_update_lock(); + } + + private function get_update_lock() { + $lock = get_transient( 'tlc_up__' . $this->key ); + if ( $lock ) + return $lock[0]; + else + return false; + } + + private function new_update_lock() { + $this->lock = uniqid( 'tlc_lock_', true ); + return $this->lock; + } + + public function fetch_and_cache() { + // If you don't supply a callback, we can't update it for you! + if ( empty( $this->callback ) ) + return false; + if ( $this->has_update_lock() && ! $this->owns_update_lock() ) + return; // Race... let the other process handle it + try { + $data = call_user_func_array( $this->callback, $this->params ); + $this->set( $data ); + } catch ( Exception $e ) { + if ( $this->extend_on_fail > 0 ) { + $data = $this->raw_get(); + if ( $data ) { + $data = $data[1]; + $old_expiration = $this->expiration; + $this->expiration = $this->extend_on_fail; + $this->set( $data ); + $this->expiration = $old_expiration; + } + } + else { + $data = false; + } + } + $this->release_update_lock(); + return $data; + } + + private function owns_update_lock() { + return $this->lock == $this->get_update_lock(); + } + + public function set( $data ) { + // We set the timeout as part of the transient data. + // The actual transient has a far-future TTL. This allows for soft expiration. + $expiration = ( $this->expiration > 0 ) ? time() + $this->expiration : 0; + $transient_expiration = ( $this->expiration > 0 ) ? $this->expiration + 31536000 : 0; // 31536000 = 60*60*24*365 ~= one year + set_transient( 'tlc__' . $this->key, array( $expiration, $data ), $transient_expiration ); + return $this; + } + + private function release_update_lock() { + delete_transient( 'tlc_up__' . $this->key ); + } + + public function spawn_server() { + $server_url = home_url( '/?tlc_transients_request' ); + wp_remote_post( $server_url, array( 'body' => array( '_tlc_update' => $this->lock, 'key' => $this->raw_key ), 'timeout' => 0.01, 'blocking' => false, 'sslverify' => apply_filters( 'https_local_ssl_verify', true ) ) ); + } + + public function updates_with( $callback, $params = array() ) { + $this->callback = $callback; + if ( is_array( $params ) ) + $this->params = $params; + return $this; + } + + public function expires_in( $seconds ) { + $this->expiration = (int) $seconds; + return $this; + } + + public function extend_on_fail( $seconds ) { + $this->extend_on_fail = (int) $seconds; + return $this; + } + + public function set_lock( $lock ) { + $this->lock = $lock; + return $this; + } + + public function background_only() { + $this->force_background_updates = true; + return $this; + } +} \ No newline at end of file diff --git a/includes/libraries/tlc-transients/composer.json b/includes/libraries/tlc-transients/composer.json new file mode 100644 index 0000000..c153d91 --- /dev/null +++ b/includes/libraries/tlc-transients/composer.json @@ -0,0 +1,21 @@ +{ + "name" : "markjaquith/wp-tlc-transients", + "description": "A WP transients interface with support for soft-expiration, background updating of the transients.", + "keywords" : ["wordpress", "cache"], + "homepage" : "https://github.com/markjaquith/WP-TLC-Transients", + "license" : "GPL-2.0+", + "authors" : [ + { + "name" : "Mark Jaquith", + "homepage": "http://markjaquith.com/" + } + ], + "support" : { + "issues": "https://github.com/markjaquith/WP-TLC-Transients/issues", + "source": "https://github.com/markjaquith/WP-TLC-Transients" + }, + "autoload" : { + "classmap": ["class-tlc-transient.php", "class-tlc-transient-update-server.php"], + "files" : ["functions.php"] + } +} \ No newline at end of file diff --git a/includes/libraries/tlc-transients/functions.php b/includes/libraries/tlc-transients/functions.php new file mode 100644 index 0000000..eaff4e4 --- /dev/null +++ b/includes/libraries/tlc-transients/functions.php @@ -0,0 +1,9 @@ + 30 ) ) ); + $f .= $append; + return $f; +} + +function test_tlc_transient() { + $t = tlc_transient( 'foo' ) + ->expires_in( 30 ) + ->background_only() + ->updates_with( 'sample_fetch_and_append', array( 'http://coveredwebservices.com/tools/long-running-request.php', ' appendfooparam ' ) ) + ->get(); + var_dump( $t ); + if ( !$t ) + echo "The request is false, because it isn't yet in the cache. It'll be there in about 10 seconds. Keep refreshing!"; +} + +add_action( 'wp_footer', 'test_tlc_transient' ); +*/ diff --git a/includes/upgrades/class-wc-subscriptions-upgrader.php b/includes/upgrades/class-wc-subscriptions-upgrader.php new file mode 100644 index 0000000..6bca8f4 --- /dev/null +++ b/includes/upgrades/class-wc-subscriptions-upgrader.php @@ -0,0 +1,714 @@ +=' ); + + self::$about_page_url = admin_url( 'index.php?page=wcs-about&wcs-updated=true' ); + + $version_out_of_date = version_compare( self::$active_version, WC_Subscriptions::$version, '<' ); + + // Set the cron lock on every request with an out of date version, regardless of authentication level, as we can only lock cron for up to 10 minutes at a time, but we need to keep it locked until the upgrade is complete, regardless of who is browing the site + if ( $version_out_of_date ) { + self::set_cron_lock(); + } + + if ( isset( $_POST['action'] ) && 'wcs_upgrade' == $_POST['action'] ) { // We're checking for CSRF in ajax_upgrade + + add_action( 'wp_ajax_wcs_upgrade', __CLASS__ . '::ajax_upgrade', 10 ); + + } elseif ( @current_user_can( 'activate_plugins' ) ) { + + if ( isset( $_GET['wcs_upgrade_step'] ) || $version_out_of_date ) { + + $is_upgrading = get_option( 'wc_subscriptions_is_upgrading', false ); + + // Check if we've exceeded the 2 minute upgrade window we use for blocking upgrades (we could seemingly use transients here to get the check for free if transients were guaranteed to exist: http://journal.rmccue.io/296/youre-using-transients-wrong/) + if ( false !== $is_upgrading && $is_upgrading < gmdate( 'U' ) ) { + $is_upgrading = false; + delete_option( 'wc_subscriptions_is_upgrading' ); + } + + if ( false !== $is_upgrading ) { + + add_action( 'init', __CLASS__ . '::upgrade_in_progress_notice', 11 ); + + } else { + + // Run upgrades as soon as admin hits site + add_action( 'wp_loaded', __CLASS__ . '::upgrade', 11 ); + + } + } elseif ( is_admin() && isset( $_GET['page'] ) && 'wcs-about' == $_GET['page'] ) { + + add_action( 'admin_menu', __CLASS__ . '::updated_welcome_page' ); + + } + } + + // While the upgrade is in progress, we need to block PayPal IPN messages to avoid renewals failing to process + add_action( 'woocommerce_api_wc_gateway_paypal', __CLASS__ . '::maybe_block_paypal_ipn', 0 ); + } + + /** + * Set limits on the number of items to upgrade at any one time based on the size of the site. + * + * The size of subscription at the time the upgrade is started is used to determine the batch size. + * + * @since 2.0 + */ + protected static function set_upgrade_limits() { + + $total_initial_subscription_count = self::get_total_subscription_count( true ); + + if ( $total_initial_subscription_count > 5000 ) { + $base_upgrade_limit = 20; + } elseif ( $total_initial_subscription_count > 1500 ) { + $base_upgrade_limit = 30; + } else { + $base_upgrade_limit = 50; + } + + self::$upgrade_limit_hooks = apply_filters( 'woocommerce_subscriptions_hooks_to_upgrade', $base_upgrade_limit * 5 ); + self::$upgrade_limit_subscriptions = apply_filters( 'woocommerce_subscriptions_to_upgrade', $base_upgrade_limit ); + } + + /** + * Try to block WP-Cron until upgrading finishes. spawn_cron() will only let us steal the lock for 10 minutes into the future, so + * we can actually only block it for 9 minutes confidently. But as long as the upgrade process continues, the lock will remain. + * + * @since 2.0 + */ + protected static function set_cron_lock() { + delete_transient( 'doing_cron' ); + set_transient( 'doing_cron', sprintf( '%.22F', 9 * MINUTE_IN_SECONDS + microtime( true ) ), 0 ); + } + + /** + * Checks which upgrades need to run and calls the necessary functions for that upgrade. + * + * @since 1.2 + */ + public static function upgrade() { + global $wpdb; + + self::set_upgrade_limits(); + + update_option( WC_Subscriptions_Admin::$option_prefix . '_previous_version', self::$active_version ); + + // Update the hold stock notification to be one week (if it's still at the default 60 minutes) to prevent cancelling subscriptions using manual renewals and payment methods that can take more than 1 hour (i.e. PayPal eCheck) + if ( '0' == self::$active_version || version_compare( self::$active_version, '1.4', '<' ) ) { + + $hold_stock_duration = get_option( 'woocommerce_hold_stock_minutes' ); + + if ( 60 == $hold_stock_duration ) { + update_option( 'woocommerce_hold_stock_minutes', 60 * 24 * 7 ); + } + + // Allow products & subscriptions to be purchased in the same transaction + update_option( 'woocommerce_subscriptions_multiple_purchase', 'yes' ); + + } + + // Keep track of site url to prevent duplicate payments from staging sites, first added in 1.3.8 & updated with 1.4.2 to work with WP Engine staging sites + if ( '0' == self::$active_version || version_compare( self::$active_version, '1.4.2', '<' ) ) { + WC_Subscriptions::set_duplicate_site_url_lock(); + } + + // Migrate products, WP-Cron hooks and subscriptions to the latest architecture, via Ajax + if ( '0' != self::$active_version && version_compare( self::$active_version, '2.0', '<' ) ) { + // Delete old cron locks + $deleted_rows = $wpdb->query( "DELETE FROM {$wpdb->options} WHERE `option_name` LIKE 'wcs\_blocker\_%'" ); + + WCS_Upgrade_Logger::add( sprintf( 'Deleted %d rows of "wcs_blocker_"', $deleted_rows ) ); + + self::ajax_upgrade_handler(); + } + + // Repair incorrect dates set when upgrading with 2.0.0 + if ( version_compare( self::$active_version, '2.0.0', '>=' ) && version_compare( self::$active_version, '2.0.2', '<' ) && self::migrated_subscription_count() > 0 ) { + self::ajax_upgrade_handler(); + } + + self::upgrade_complete(); + } + + /** + * When an upgrade is complete, set the active version, delete the transient locking upgrade and fire a hook. + * + * @since 1.2 + */ + public static function upgrade_complete() { + + update_option( WC_Subscriptions_Admin::$option_prefix . '_active_version', WC_Subscriptions::$version ); + + delete_transient( 'doing_cron' ); + + delete_option( 'wc_subscriptions_is_upgrading' ); + + do_action( 'woocommerce_subscriptions_upgraded', WC_Subscriptions::$version ); + } + + /** + * Add support for quantities for subscriptions. + * Update all current subscription wp_cron tasks to the new action-scheduler system. + * + * @since 2.0 + */ + private static function ajax_upgrade_handler() { + + $_GET['wcs_upgrade_step'] = ( ! isset( $_GET['wcs_upgrade_step'] ) ) ? 0 : $_GET['wcs_upgrade_step']; + + switch ( (int) $_GET['wcs_upgrade_step'] ) { + case 1: + self::display_database_upgrade_helper(); + break; + case 3: // keep a way to circumvent the upgrade routine just in case + self::upgrade_complete(); + wp_safe_redirect( self::$about_page_url ); + break; + case 0: + default: + wp_safe_redirect( admin_url( 'admin.php?wcs_upgrade_step=1' ) ); + break; + } + + exit(); + } + + /** + * Move scheduled subscription hooks out of wp-cron and into the new Action Scheduler. + * + * Also set all existing subscriptions to "sold individually" to maintain previous behavior + * for existing subscription products before the subscription quantities feature was enabled.. + * + * @since 1.5 + */ + public static function ajax_upgrade() { + global $wpdb; + + check_admin_referer( 'wcs_upgrade_process', 'nonce' ); + + self::set_upgrade_limits(); + + WCS_Upgrade_Logger::add( sprintf( 'Starting upgrade step: %s', $_POST['upgrade_step'] ) ); + + if ( ini_get( 'max_execution_time' ) < 600 ) { + @set_time_limit( 600 ); + } + + @ini_set( 'memory_limit', apply_filters( 'admin_memory_limit', WP_MAX_MEMORY_LIMIT ) ); + + update_option( 'wc_subscriptions_is_upgrading', gmdate( 'U' ) + 60 * 2 ); + + switch ( $_POST['upgrade_step'] ) { + + case 'really_old_version': + $upgraded_versions = self::upgrade_really_old_versions(); + $results = array( + // translators: placeholder is a list of version numbers (e.g. "1.3 & 1.4 & 1.5") + 'message' => sprintf( __( 'Database updated to version %s', 'woocommerce-subscriptions' ), $upgraded_versions ), + ); + break; + + case 'products': + + require_once( 'class-wcs-upgrade-1-5.php' ); + + $upgraded_product_count = WCS_Upgrade_1_5::upgrade_products(); + $results = array( + // translators: placeholder is number of upgraded subscriptions + 'message' => sprintf( _x( 'Marked %s subscription products as "sold individually".', 'used in the subscriptions upgrader', 'woocommerce-subscriptions' ), $upgraded_product_count ), + ); + break; + + case 'hooks': + + require_once( 'class-wcs-upgrade-1-5.php' ); + + $upgraded_hook_count = WCS_Upgrade_1_5::upgrade_hooks( self::$upgrade_limit_hooks ); + $results = array( + 'upgraded_count' => $upgraded_hook_count, + // translators: 1$: number of action scheduler hooks upgraded, 2$: "{execution_time}", will be replaced on front end with actual time + 'message' => sprintf( __( 'Migrated %1$s subscription related hooks to the new scheduler (in %2$s seconds).', 'woocommerce-subscriptions' ), $upgraded_hook_count, '{execution_time}' ), + ); + break; + + case 'subscriptions': + + require_once( 'class-wcs-repair-2-0.php' ); + require_once( 'class-wcs-upgrade-2-0.php' ); + + try { + + $upgraded_subscriptions = WCS_Upgrade_2_0::upgrade_subscriptions( self::$upgrade_limit_subscriptions ); + + $results = array( + 'upgraded_count' => $upgraded_subscriptions, + // translators: 1$: number of subscriptions upgraded, 2$: "{execution_time}", will be replaced on front end with actual time it took + 'message' => sprintf( __( 'Migrated %1$s subscriptions to the new structure (in %2$s seconds).', 'woocommerce-subscriptions' ), $upgraded_subscriptions, '{execution_time}' ), + 'status' => 'success', + // translators: placeholder is "{time_left}", will be replaced on front end with actual time + 'time_message' => sprintf( _x( 'Estimated time left (minutes:seconds): %s', 'Message that gets sent to front end.', 'woocommerce-subscriptions' ), '{time_left}' ), + ); + + } catch ( Exception $e ) { + + WCS_Upgrade_Logger::add( sprintf( 'Error on upgrade step: %s. Error: %s', $_POST['upgrade_step'], $e->getMessage() ) ); + + $results = array( + 'upgraded_count' => 0, + // translators: 1$: error message, 2$: opening link tag, 3$: closing link tag + 'message' => sprintf( __( 'Unable to upgrade subscriptions.
    Error: %1$s
    Please refresh the page and try again. If problem persists, %2$scontact support%3$s.', 'woocommerce-subscriptions' ), '' . $e->getMessage(). '', '', '' ), + 'status' => 'error', + ); + } + + break; + + case 'subscription_dates_repair': + + require_once( 'class-wcs-upgrade-2-0.php' ); + require_once( 'class-wcs-repair-2-0-2.php' ); + + $subscription_ids_to_repair = WCS_Repair_2_0_2::get_subscriptions_to_repair( self::$upgrade_limit_subscriptions ); + + try { + + $subscription_counts = WCS_Repair_2_0_2::maybe_repair_subscriptions( $subscription_ids_to_repair ); + + // translators: placeholder is the number of subscriptions repaired + $repair_incorrect = sprintf( _x( 'Repaired %d subscriptions with incorrect dates, line tax data or missing customer notes.', 'Repair message that gets sent to front end.', 'woocommerce-subscriptions' ), $subscription_counts['repaired_count'] ); + + $repair_not_needed = ''; + + if ( $subscription_counts['unrepaired_count'] > 0 ) { + // translators: placeholder is number of subscriptions that were checked and did not need repairs. There's a space at the beginning! + $repair_not_needed = sprintf( _nx( ' %d other subscription was checked and did not need any repairs.', '%d other subscriptions were checked and did not need any repairs.', $subscription_counts['unrepaired_count'], 'Repair message that gets sent to front end.', 'woocommerce-subscriptions' ), $subscription_counts['unrepaired_count'] ); + } + + // translators: placeholder is "{execution_time}", which will be replaced on front end with actual time + $repair_time = sprintf( _x( '(in %s seconds)', 'Repair message that gets sent to front end.', 'woocommerce-subscriptions' ), '{execution_time}' ); + + // translators: $1: "Repaired x subs with incorrect dates...", $2: "X others were checked and no repair needed", $3: "(in X seconds)". Ordering for RTL languages. + $repair_message = sprintf( _x( '%1$s%2$s %3$s', 'The assembled repair message that gets sent to front end.', 'woocommerce-subscriptions' ), $repair_incorrect, $repair_not_needed, $repair_time ); + + $results = array( + 'repaired_count' => $subscription_counts['repaired_count'], + 'unrepaired_count' => $subscription_counts['unrepaired_count'], + 'message' => $repair_message, + 'status' => 'success', + // translators: placeholder is "{time_left}", will be replaced on front end with actual time + 'time_message' => sprintf( _x( 'Estimated time left (minutes:seconds): %s', 'Message that gets sent to front end.', 'woocommerce-subscriptions' ), '{time_left}' ), + ); + + } catch ( Exception $e ) { + + WCS_Upgrade_Logger::add( sprintf( 'Error on upgrade step: %s. Error: %s', $_POST['upgrade_step'], $e->getMessage() ) ); + + $results = array( + 'repaired_count' => 0, + 'unrepaired_count' => 0, + // translators: 1$: error message, 2$: opening link tag, 3$: closing link tag + 'message' => sprintf( _x( 'Unable to repair subscriptions.
    Error: %1$s
    Please refresh the page and try again. If problem persists, %2$scontact support%3$s.', 'Error message that gets sent to front end when upgrading Subscriptions', 'woocommerce-subscriptions' ), '' . $e->getMessage(). '', '', '' ), + 'status' => 'error', + ); + } + + break; + } + + if ( 'subscriptions' == $_POST['upgrade_step'] && 0 === self::get_total_subscription_count_query() ) { + + self::upgrade_complete(); + + } elseif ( 'subscription_dates_repair' == $_POST['upgrade_step'] ) { + + $subscriptions_to_repair = WCS_Repair_2_0_2::get_subscriptions_to_repair( self::$upgrade_limit_subscriptions ); + + if ( empty( $subscriptions_to_repair ) ) { + self::upgrade_complete(); + } + } + + WCS_Upgrade_Logger::add( sprintf( 'Completed upgrade step: %s', $_POST['upgrade_step'] ) ); + + header( 'Content-Type: application/json; charset=utf-8' ); + echo wcs_json_encode( $results ); + exit(); + } + + /** + * Handle upgrades for really old versions. + * + * @since 2.0 + */ + private static function upgrade_really_old_versions() { + + if ( '0' != self::$active_version && version_compare( self::$active_version, '1.2', '<' ) ) { + include_once( 'class-wcs-upgrade-1-2.php' ); + self::generate_renewal_orders(); + update_option( WC_Subscriptions_Admin::$option_prefix . '_active_version', '1.2' ); + $upgraded_versions = '1.2, '; + } + + // Add Variable Subscription product type term + if ( '0' != self::$active_version && version_compare( self::$active_version, '1.3', '<' ) ) { + include_once( 'class-wcs-upgrade-1-3.php' ); + update_option( WC_Subscriptions_Admin::$option_prefix . '_active_version', '1.3' ); + $upgraded_versions .= '1.3 & '; + } + + // Moving subscription meta out of user meta and into item meta + if ( '0' != self::$active_version && version_compare( self::$active_version, '1.4', '<' ) ) { + include_once( 'class-wcs-upgrade-1-4.php' ); + update_option( WC_Subscriptions_Admin::$option_prefix . '_active_version', '1.4' ); + $upgraded_versions .= '1.4.'; + } + + return $upgraded_versions; + } + + /** + * Version 1.2 introduced child renewal orders to keep a record of each completed subscription + * payment. Before 1.2, these orders did not exist, so this function creates them. + * + * @since 1.2 + */ + private static function generate_renewal_orders() { + global $wpdb; + $woocommerce = WC(); + + $subscriptions_grouped_by_user = WC_Subscriptions_Manager::get_all_users_subscriptions(); + + // Don't send any order emails + $email_actions = array( 'woocommerce_low_stock', 'woocommerce_no_stock', 'woocommerce_product_on_backorder', 'woocommerce_order_status_pending_to_processing', 'woocommerce_order_status_pending_to_completed', 'woocommerce_order_status_pending_to_on-hold', 'woocommerce_order_status_failed_to_processing', 'woocommerce_order_status_failed_to_completed', 'woocommerce_order_status_pending_to_processing', 'woocommerce_order_status_pending_to_on-hold', 'woocommerce_order_status_completed', 'woocommerce_new_customer_note' ); + foreach ( $email_actions as $action ) { + remove_action( $action, array( &$woocommerce, 'send_transactional_email' ) ); + } + + remove_action( 'woocommerce_payment_complete', 'WC_Subscriptions_Renewal_Order::maybe_record_renewal_order_payment', 10, 1 ); + + foreach ( $subscriptions_grouped_by_user as $user_id => $users_subscriptions ) { + foreach ( $users_subscriptions as $subscription_key => $subscription ) { + $order_post = get_post( $subscription['order_id'] ); + + if ( isset( $subscription['completed_payments'] ) && count( $subscription['completed_payments'] ) > 0 && null != $order_post ) { + foreach ( $subscription['completed_payments'] as $payment_date ) { + + $existing_renewal_order = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_date_gmt = %s AND post_parent = %d AND post_type = 'shop_order'", $payment_date, $subscription['order_id'] ) ); + + // If a renewal order exists on this date, don't generate another one + if ( null !== $existing_renewal_order ) { + continue; + } + + $renewal_order_id = WC_Subscriptions_Renewal_Order::generate_renewal_order( $subscription['order_id'], $subscription['product_id'], array( 'new_order_role' => 'child' ) ); + + if ( $renewal_order_id ) { + + // Mark the order as paid + $renewal_order = new WC_Order( $renewal_order_id ); + + $renewal_order->payment_complete(); + + // Avoid creating 100s "processing" orders + $renewal_order->update_status( 'completed' ); + + // Set correct dates on the order + $renewal_order = array( + 'ID' => $renewal_order_id, + 'post_date' => $payment_date, + 'post_date_gmt' => $payment_date, + ); + wp_update_post( $renewal_order ); + + update_post_meta( $renewal_order_id, '_paid_date', $payment_date ); + update_post_meta( $renewal_order_id, '_completed_date', $payment_date ); + + } + } + } + } + } + } + + /** + * Let the site administrator know we are upgrading the database and provide a confirmation is complete. + * + * This is important to avoid the possibility of a database not upgrading correctly, but the site continuing + * to function without any remedy. + * + * @since 1.2 + */ + public static function display_database_upgrade_helper() { + + wp_register_style( 'wcs-upgrade', plugins_url( '/assets/css/wcs-upgrade.css', WC_Subscriptions::$plugin_file ) ); + wp_register_script( 'wcs-upgrade', plugins_url( '/assets/js/wcs-upgrade.js', WC_Subscriptions::$plugin_file ), 'jquery' ); + + if ( version_compare( self::$active_version, '2.0.0', '<' ) ) { + // We're running the 2.0 upgrade routine + $subscription_count = self::get_total_subscription_count(); + } elseif ( version_compare( self::$active_version, '2.0.0', '>=' ) && version_compare( self::$active_version, '2.0.2', '<' ) ) { + // We're running the 2.0.2 repair routine + $subscription_counts = wp_count_posts( 'shop_subscription' ); + $subscription_count = array_sum( (array) $subscription_counts ) - $subscription_counts->trash - $subscription_counts->{'auto-draft'}; + } else { + // How did we get here? + $subscription_count = 0; + } + + $script_data = array( + 'really_old_version' => ( version_compare( self::$active_version, '1.4', '<' ) ) ? 'true' : 'false', + 'upgrade_to_1_5' => ( version_compare( self::$active_version, '1.5', '<' ) ) ? 'true' : 'false', + 'upgrade_to_2_0' => ( version_compare( self::$active_version, '2.0.0', '<' ) ) ? 'true' : 'false', + 'repair_2_0' => ( version_compare( self::$active_version, '2.0.0', '>=' ) && version_compare( self::$active_version, '2.0.2', '<' ) ) ? 'true' : 'false', + 'hooks_per_request' => self::$upgrade_limit_hooks, + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'upgrade_nonce' => wp_create_nonce( 'wcs_upgrade_process' ), + 'subscription_count' => $subscription_count, + ); + + wp_localize_script( 'wcs-upgrade', 'wcs_update_script_data', $script_data ); + + // Can't get subscription count with database structure < 1.4 + if ( 'false' == $script_data['really_old_version'] ) { + + // The base duration is 50 subscriptions per minute (i.e. approximately 60 seconds per batch of 50) + $estimated_duration = ceil( $subscription_count / 50 ); + + // Large sites take about 2-3x as long (i.e. approximately 80 seconds per batch of 35) + if ( $subscription_count > 5000 ) { + $estimated_duration *= 3; + } + + // And really large sites take around 5-6x as long (i.e. approximately 100 seconds per batch of 25) + if ( $subscription_count > 10000 ) { + $estimated_duration *= 2; + } + } + + $about_page_url = self::$about_page_url; + + @header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) ); + include_once( 'templates/wcs-upgrade.php' ); + WCS_Upgrade_Logger::add( 'Loaded database upgrade helper' ); + } + + /** + * Let the site administrator know we are upgrading the database already to prevent duplicate processes running the + * upgrade. Also provides some useful diagnostic information, like how long before the site admin can restart the + * upgrade process, and how many subscriptions per request can typically be updated given the amount of memory + * allocated to PHP. + * + * @since 1.4 + */ + public static function upgrade_in_progress_notice() { + include_once( 'templates/wcs-upgrade-in-progress.php' ); + WCS_Upgrade_Logger::add( 'Loaded database upgrade in progress notice...' ); + } + + /** + * Display the Subscriptions welcome/about page after successfully upgrading to the latest version. + * + * @since 1.4 + */ + public static function updated_welcome_page() { + $about_page = add_dashboard_page( __( 'Welcome to WooCommerce Subscriptions 2.0', 'woocommerce-subscriptions' ), __( 'About WooCommerce Subscriptions', 'woocommerce-subscriptions' ), 'manage_options', 'wcs-about', __CLASS__ . '::about_screen' ); + add_action( 'admin_print_styles-'. $about_page, __CLASS__ . '::admin_css' ); + add_action( 'admin_head', __CLASS__ . '::admin_head' ); + } + + /** + * admin_css function. + * + * @access public + * @return void + */ + public static function admin_css() { + wp_enqueue_style( 'woocommerce-subscriptions-about', plugins_url( '/assets/css/about.css', WC_Subscriptions::$plugin_file ), array(), self::$active_version ); + } + + /** + * Add styles just for this page, and remove dashboard page links. + * + * @access public + * @return void + */ + public static function admin_head() { + remove_submenu_page( 'index.php', 'wcs-about' ); + } + + /** + * Output the about screen. + */ + public static function about_screen() { + $active_version = self::$active_version; + include_once( 'templates/wcs-about.php' ); + } + + /** + * In v2.0 and newer, it's possible to simply use wp_count_posts( 'shop_subscription' ) to count subscriptions, + * but not in v1.5, because a subscription data is still stored in order item meta. This function queries the + * v1.5 database structure. + * + * @since 2.0 + */ + private static function get_total_subscription_count( $initial = false ) { + + if ( $initial ) { + + $subscription_count = get_option( 'wcs_upgrade_initial_total_subscription_count', false ); + + if ( false === $subscription_count ) { + $subscription_count = self::get_total_subscription_count(); + update_option( 'wcs_upgrade_initial_total_subscription_count', $subscription_count ); + } + } else { + + if ( null === self::$old_subscription_count ) { + self::$old_subscription_count = self::get_total_subscription_count_query(); + } + + $subscription_count = self::$old_subscription_count; + } + + return $subscription_count; + } + + /** + * Returns the number of subscriptions left in the 1.5 structure + * @return integer number of 1.5 subscriptions left + */ + private static function get_total_subscription_count_query() { + global $wpdb; + + $query = self::get_subscription_query(); + + $wpdb->get_results( $query ); + + return $wpdb->num_rows; + } + + /** + * Single source of truth for the query + * @param integer $limit the number of subscriptions to get + * @return string SQL query of what we need + */ + public static function get_subscription_query( $batch_size = null ) { + global $wpdb; + + if ( null === $batch_size ) { + $select = 'SELECT DISTINCT items.order_item_id'; + $limit = ''; + } else { + $select = 'SELECT meta.*, items.*'; + $limit = sprintf( ' LIMIT 0, %d', $batch_size ); + } + + $query = sprintf( "%s FROM `{$wpdb->prefix}woocommerce_order_itemmeta` AS meta + LEFT JOIN `{$wpdb->prefix}woocommerce_order_items` AS items USING (order_item_id) + LEFT JOIN ( + SELECT a.order_item_id FROM `{$wpdb->prefix}woocommerce_order_itemmeta` AS a + LEFT JOIN ( + SELECT `{$wpdb->prefix}woocommerce_order_itemmeta`.order_item_id FROM `{$wpdb->prefix}woocommerce_order_itemmeta` + WHERE `{$wpdb->prefix}woocommerce_order_itemmeta`.meta_key = '_subscription_status' + ) AS s + USING (order_item_id) + WHERE 1=1 + AND a.order_item_id = s.order_item_id + AND a.meta_key = '_subscription_start_date' + ORDER BY CASE WHEN CAST(a.meta_value AS DATETIME) IS NULL THEN 1 ELSE 0 END, CAST(a.meta_value AS DATETIME) ASC + %s + ) AS a3 USING (order_item_id) + WHERE meta.meta_key REGEXP '_subscription_(.*)|_product_id|_variation_id' + AND meta.order_item_id = a3.order_item_id + AND items.order_item_id IS NOT NULL", $select, $limit ); + + return $query; + } + + /** + * Check if the database has some data that was migrated from 1.5 to 2.0 + * + * @return bool True if it detects some v1.5 migrated data, otherwise false + */ + protected static function migrated_subscription_count() { + global $wpdb; + + $migrated_subscription_count = $wpdb->get_var( + "SELECT COUNT(DISTINCT `post_id`) FROM $wpdb->postmeta + WHERE `meta_key` LIKE '%wcs\_migrated%'" + ); + + return $migrated_subscription_count; + } + + /** + * While the upgrade is in progress, we need to block IPN messages to avoid renewals failing to process correctly. + * + * PayPal will retry the IPNs for up to a day or two until it has a successful request, so the store will continue to receive + * IPN messages during the upgrade process, then once it is completed, the IPN will be successfully processed. + * + * The method returns a 409 Conflict HTTP response code to indicate that the IPN is conflicting with the upgrader. + * + * @since 2.0 + */ + public static function maybe_block_paypal_ipn() { + if ( false !== get_option( 'wc_subscriptions_is_upgrading', false ) ) { + WCS_Upgrade_Logger::add( '*** PayPal IPN Request blocked: ' . print_r( wp_unslash( $_POST ), true ) ); // No CSRF needed as it's from outside + wp_die( 'PayPal IPN Request Failure', 'PayPal IPN', array( 'response' => 409 ) ); + } + } + + /** + * Used to check if a user ID is greater than the last user upgraded to version 1.4. + * + * Needs to be a separate function so that it can use a static variable (and therefore avoid calling get_option() thousands + * of times when iterating over thousands of users). + * + * @since 1.4 + */ + public static function is_user_upgraded_to_1_4( $user_id ) { + _deprecated_function( __METHOD__, '2.0', 'WCS_Upgrade_1_4::is_user_upgraded( $user_id )' ); + return WCS_Upgrade_1_4::is_user_upgraded( $user_id ); + } +} +add_action( 'after_setup_theme', 'WC_Subscriptions_Upgrader::init', 11 ); diff --git a/includes/upgrades/class-wcs-repair-2-0-2.php b/includes/upgrades/class-wcs-repair-2-0-2.php new file mode 100644 index 0000000..6b66cf2 --- /dev/null +++ b/includes/upgrades/class-wcs-repair-2-0-2.php @@ -0,0 +1,412 @@ + 'shop_subscription', + 'post_status' => 'any', + 'posts_per_page' => $batch_size, + 'fields' => 'ids', + 'orderby' => 'ID', + 'order' => 'ASC', + 'meta_query' => array( + array( + 'key' => '_wcs_repaired_2_0_2', + 'compare' => 'NOT EXISTS', + ), + ), + ) ); + + return $subscription_ids_to_repair; + } + + /** + * Update any subscription that need to be repaired. + * + * @return array The counts of repaired and unrepaired subscriptions + */ + public static function maybe_repair_subscriptions( $subscription_ids_to_repair ) { + global $wpdb; + + // don't allow data to be half upgraded on a subscription in case of a script timeout or other non-recoverable error + $wpdb->query( 'START TRANSACTION' ); + + $repaired_count = $unrepaired_count = 0; + + foreach ( $subscription_ids_to_repair as $subscription_id ) { + + $subscription = wcs_get_subscription( $subscription_id ); + + if ( false !== $subscription && self::maybe_repair_subscription( $subscription ) ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair completed', $subscription->id ) ); + $repaired_count++; + update_post_meta( $subscription_id, '_wcs_repaired_2_0_2', 'true' ); + } else { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no repair needed', $subscription->id ) ); + $unrepaired_count++; + update_post_meta( $subscription_id, '_wcs_repaired_2_0_2', 'false' ); + } + } + + $wpdb->query( 'COMMIT' ); + + return array( + 'repaired_count' => $repaired_count, + 'unrepaired_count' => $unrepaired_count, + ); + + } + + /** + * Check if a subscription was created prior to 2.0.0 and has some dates that need to be updated + * because the meta was borked during the 2.0.0 upgrade process. If it does, then update the dates + * to the new values. + * + * @return bool true if the subscription was repaired, otherwise false + */ + protected static function maybe_repair_subscription( $subscription ) { + + $repaired_subscription = false; + + // if the subscription doesn't have an order, it must have been created in 2.0, so we can ignore it + if ( false === $subscription->order ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: it has no order.', $subscription->id ) ); + return $repaired_subscription; + } + + $subscription_line_items = $subscription->get_items(); + + // if the subscription has more than one line item, it must have been created in 2.0, so we can ignore it + if ( count( $subscription_line_items ) > 1 ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: it has more than one line item.', $subscription->id ) ); + return $repaired_subscription; + } + + $subscription_line_item_id = key( $subscription_line_items ); + $subscription_line_item = array_shift( $subscription_line_items ); + + // Get old order item's meta + foreach ( $subscription->order->get_items() as $line_item_id => $line_item ) { + if ( wcs_get_canonical_product_id( $line_item ) == wcs_get_canonical_product_id( $subscription_line_item ) ) { + $matching_line_item_id = $line_item_id; + $matching_line_item = $line_item; + break; + } + } + + // we couldn't find a matching line item so we can't repair it + if ( ! isset( $matching_line_item ) ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: can not repair: it has no matching line item.', $subscription->id ) ); + return $repaired_subscription; + } + + $matching_line_item_meta = $matching_line_item['item_meta']; + + // if the order item doesn't have migrated subscription data, the subscription wasn't migrated from 1.5 + if ( ! isset( $matching_line_item_meta['_wcs_migrated_subscription_status'] ) && ! isset( $matching_line_item_meta['_wcs_migrated_subscription_start_date'] ) ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: matching line item has no migrated meta data.', $subscription->id ) ); + return $repaired_subscription; + } + + if ( false !== self::maybe_repair_line_tax_data( $subscription_line_item_id, $matching_line_item_id, $matching_line_item ) ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired missing line tax data.', $subscription->id ) ); + $repaired_subscription = true; + } else { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: line tax data not added.', $subscription->id ) ); + } + + // if the subscription has been cancelled, we don't need to repair any other data + if ( $subscription->has_status( array( 'pending-cancel', 'cancelled' ) ) ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: it has cancelled status.', $subscription->id ) ); + return $repaired_subscription; + } + + $dates_to_update = array(); + + if ( false !== ( $repair_date = self::check_trial_end_date( $subscription, $matching_line_item_meta ) ) ) { + $dates_to_update['trial_end'] = $repair_date; + } + + if ( false !== ( $repair_date = self::check_next_payment_date( $subscription ) ) ) { + $dates_to_update['next_payment'] = $repair_date; + } + + if ( false !== ( $repair_date = self::check_end_date( $subscription, $matching_line_item_meta ) ) ) { + $dates_to_update['end'] = $repair_date; + } + + if ( ! empty( $dates_to_update ) ) { + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repairing dates = %s', $subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ) ) ); + + try { + $subscription->update_dates( $dates_to_update ); + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired dates = %s', $subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ) ) ); + } catch ( Exception $e ) { + WCS_Upgrade_Logger::add( sprintf( '!! For subscription %d: unable to repair dates (%s), exception "%s"', $subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ), $e->getMessage() ) ); + } + + try { + self::maybe_repair_status( $subscription, $matching_line_item_meta, $dates_to_update ); + } catch ( Exception $e ) { + WCS_Upgrade_Logger::add( sprintf( '!! For subscription %d: unable to repair status. Exception: "%s"', $subscription->id, $e->getMessage() ) ); + } + + $repaired_subscription = true; + } + + if ( ! empty( $subscription->order->customer_note ) && empty( $subscription->customer_note ) ) { + + $post_data = array( + 'ID' => $subscription->id, + 'post_excerpt' => $subscription->order->customer_note, + ); + + $updated_post_id = wp_update_post( $post_data, true ); + + if ( ! is_wp_error( $updated_post_id ) ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired missing customer note.', $subscription->id ) ); + $repaired_subscription = true; + } else { + WCS_Upgrade_Logger::add( sprintf( '!! For subscription %d: unable to repair missing customer note. Exception: "%s"', $subscription->id, $updated_post_id->get_error_message() ) ); + } + } + + return $repaired_subscription; + } + + /** + * If we have a trial end date and that value is not the same as the old end date prior to upgrade, it was most likely + * corrupted, so we will reset it to the value in meta. + * + * @param WC_Subscription $subscription the subscription to check + * @param array $former_order_item_meta the order item meta data for the line item on the original order that formerly represented the subscription + * @return string|bool false if the date does not need to be repaired or the new date if it should be repaired + */ + protected static function check_trial_end_date( $subscription, $former_order_item_meta ) { + + $new_trial_end_time = $subscription->get_time( 'trial_end' ); + + if ( $new_trial_end_time > 0 ) { + + $old_trial_end_date = isset( $former_order_item_meta['_wcs_migrated_subscription_trial_expiry_date'][0] ) ? $former_order_item_meta['_wcs_migrated_subscription_trial_expiry_date'][0] : 0; + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new trial end date = %s.', $subscription->id, var_export( $subscription->get_date( 'trial_end' ), true ) ) ); + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old trial end date = %s.', $subscription->id, var_export( $old_trial_end_date, true ) ) ); + + // if the subscription has a trial end time whereas previously it didn't, we need it to be deleted + if ( 0 == $old_trial_end_date ) { + $repair_date = 0; + } else { + $repair_date = false; + } + } else { + $repair_date = false; + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair trial end date = %s.', $subscription->id, var_export( $repair_date, true ) ) ); + + return $repair_date; + } + + /** + * Because the upgrader may have attempted to set an invalid end date on the subscription, it could + * lead to the entire date update process failing, which would mean that a next payment date would + * not be set even when one existed. + * + * This method checks if a given subscription has no next payment date, and if it doesn't, it checks + * if one was previously scheduled for the old subscription. If one was, and that date is in the future, + * it will pass that date back for being set on the subscription. If a date was scheduled but that is now + * in the past, it will recalculate it. + * + * @param WC_Subscription $subscription the subscription to check + * @return string|bool false if the date does not need to be repaired or the new date if it should be repaired + */ + protected static function check_next_payment_date( $subscription ) { + global $wpdb; + + // the subscription doesn't have a next payment date set, let's see if it should + if ( 0 == $subscription->get_time( 'next_payment' ) && $subscription->has_status( 'active' ) ) { + + $old_hook_args = array( + 'user_id' => (int) $subscription->get_user_id(), + 'subscription_key' => wcs_get_old_subscription_key( $subscription ), + ); + + // get the latest scheduled subscription payment in v1.5 + $old_next_payment_date = $wpdb->get_var( $wpdb->prepare( + "SELECT post_date_gmt FROM $wpdb->posts + WHERE post_type = %s + AND post_content = %s + AND post_title = 'scheduled_subscription_payment' + ORDER BY post_date_gmt DESC", + ActionScheduler_wpPostStore::POST_TYPE, + wcs_json_encode( $old_hook_args ) + ) ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new next payment date = %s.', $subscription->id, var_export( $subscription->get_date( 'next_payment' ), true ) ) ); + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old next payment date = %s.', $subscription->id, var_export( $old_next_payment_date, true ) ) ); + + // if we have a date, make sure it's valid + if ( null !== $old_next_payment_date ) { + if ( strtotime( $old_next_payment_date ) <= gmdate( 'U' ) ) { + $repair_date = $subscription->calculate_date( 'next_payment' ); + if ( 0 == $repair_date ) { + $repair_date = false; + } + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old next payment date is in the past, setting it to %s.', $subscription->id, var_export( $repair_date, true ) ) ); + } else { + $repair_date = $old_next_payment_date; + } + } else { + + // let's just double check we shouldn't have a date set by recalculating it + $calculated_next_payment_date = $subscription->calculate_date( 'next_payment' ); + + if ( 0 != $calculated_next_payment_date && strtotime( $calculated_next_payment_date ) > gmdate( 'U' ) ) { + $repair_date = $calculated_next_payment_date; + } else { + $repair_date = false; + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no old next payment date, setting it to %s.', $subscription->id, var_export( $repair_date, true ) ) ); + } + } else { + $repair_date = false; + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair next payment date = %s.', $subscription->id, var_export( $repair_date, true ) ) ); + + return $repair_date; + } + + /** + * Check if the old subscription meta had an end date recorded and make sure that end date is now being used for the new subscription. + * + * In Subscriptions prior to 2.0 a subscription could have both an end date and an expiration date. The end date represented a date in the past + * on which the subscription expired or was cancelled. The expiration date represented a date on which the subscription was set to expire (this + * could be in the past or future and could be the same as the end date or different). Because the end date is a definitive even, in this function + * we first check if it exists before falling back to the expiration date to check against. + * + * @param WC_Subscription $subscription the subscription to check + * @param array $former_order_item_meta the order item meta data for the line item on the original order that formerly represented the subscription + * @return string|bool false if the date does not need to be repaired or the new date if it should be repaired + */ + protected static function check_end_date( $subscription, $former_order_item_meta ) { + + $new_end_time = $subscription->get_time( 'end' ); + + if ( $new_end_time > 0 ) { + + $old_end_date = isset( $former_order_item_meta['_wcs_migrated_subscription_end_date'][0] ) ? $former_order_item_meta['_wcs_migrated_subscription_end_date'][0] : 0; + + // if the subscription hadn't expired or been cancelled yet, it wouldn't have an end date, but it may still have had an expiry date, so use that instead + if ( 0 == $old_end_date ) { + $old_end_date = isset( $former_order_item_meta['_wcs_migrated_subscription_expiry_date'][0] ) ? $former_order_item_meta['_wcs_migrated_subscription_expiry_date'][0] : 0; + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new end date = %s.', $subscription->id, var_export( $subscription->get_date( 'end' ), true ) ) ); + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old end date = %s.', $subscription->id, var_export( $old_end_date, true ) ) ); + + // if the subscription has an end time whereas previously it didn't, we need it to be deleted so set it 0 + if ( 0 == $old_end_date ) { + $repair_date = 0; + } else { + $repair_date = false; + } + } else { + $repair_date = false; + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair end date = %s.', $subscription->id, var_export( $repair_date, true ) ) ); + + return $repair_date; + } + + /** + * If the subscription has expired since upgrading and the end date is not the original expiration date, + * we need to unexpire it, which in the case of a previously active subscription means activate it, and + * in any other case, leave it as on-hold (a cancelled subscription wouldn't have been expired, so the + * status must be on-hold or active). + * + * @param WC_Subscription $subscription data about the subscription + * @return bool true if the trial date was repaired, otherwise false + */ + protected static function maybe_repair_status( $subscription, $former_order_item_meta, $dates_to_update ) { + + if ( $subscription->has_status( 'expired' ) && 'expired' != $former_order_item_meta['_wcs_migrated_subscription_status'][0] && isset( $dates_to_update['end'] ) ) { + + try { + + // we need to bypass the update_status() method here because normally an expired subscription can't have it's status changed, we also don't want normal status change hooks to be fired + wp_update_post( array( 'ID' => $subscription->id, 'post_status' => 'wc-on-hold' ) ); + + // if the payment method doesn't support date changes, we still want to reactivate the subscription but we also need to process a special failed payment at the next renewal to fix up the payment method so we'll set a special flag in post meta to handle that + if ( ! $subscription->payment_method_supports( 'subscription_date_changes' ) && $subscription->get_total() > 0 ) { + update_post_meta( $subscription->id, '_wcs_repaired_2_0_2_needs_failed_payment', 'true' ); + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: payment method does not support "subscription_date_changes" and total > 0, setting "_wcs_repaired_2_0_2_needs_failed_payment" post meta flag.', $subscription->id ) ); + } + + if ( 'active' == $former_order_item_meta['_wcs_migrated_subscription_status'][0] && $subscription->can_be_updated_to( 'active' ) ) { + $subscription->update_status( 'active' ); + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired status. Status was "expired", it is now "%s".', $subscription->id, $subscription->get_status() ) ); + $repair_status = true; + + } catch ( Exception $e ) { + WCS_Upgrade_Logger::add( sprintf( '!!! For subscription %d: unable to repair status, exception "%s"', $subscription->id, $e->getMessage() ) ); + $repair_status = false; + } + } else { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair status, current status: %s; former status: %s.', $subscription->id, $subscription->get_status(), $former_order_item_meta['_wcs_migrated_subscription_status'][0] ) ); + $repair_status = false; + } + return $repair_status; + } + + /** + * There was a bug in the WCS_Upgrade_2_0::add_line_tax_data() method in Subscriptions 2.0.0 and 2.0.1 which + * prevented recurring line tax data from being copied correctly to newly created subscriptions. This bug was + * fixed in 2.0.2, so we can now use that method to make sure line tax data is set correctly. But to do that, + * we first need to massage some of the deprecated line item meta to use the original meta keys. + * + * @param int $subscription_line_item_id ID of the new subscription line item + * @param int $old_order_item_id ID of the old order line item + * @param array $old_order_item The old line item + * @return bool|int the meta ID of the newly added '_line_tax_data' meta data row, or false if no line tax data was added. + */ + protected static function maybe_repair_line_tax_data( $subscription_line_item_id, $old_order_item_id, $old_order_item ) { + + // we need item meta in the old format so that we can use the (now fixed) WCS_Upgrade_2_0::add_line_tax_data() method and save duplicating its code + $old_order_item['item_meta']['_recurring_line_total'] = isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_total'] ) ? $old_order_item['item_meta']['_wcs_migrated_recurring_line_total']: 0; + $old_order_item['item_meta']['_recurring_line_tax'] = isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax'] ) ? $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax'] : 0; + $old_order_item['item_meta']['_recurring_line_subtotal_tax'] = isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_subtotal_tax'] ) ? $old_order_item['item_meta']['_wcs_migrated_recurring_line_subtotal_tax'] : 0; + + if ( isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax_data'] ) ) { + $old_order_item['item_meta']['_recurring_line_tax_data'] = $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax_data']; + } + + return WCS_Upgrade_2_0::add_line_tax_data( $subscription_line_item_id, $old_order_item_id, $old_order_item ); + } +} diff --git a/includes/upgrades/class-wcs-repair-2-0.php b/includes/upgrades/class-wcs-repair-2-0.php new file mode 100644 index 0000000..d792baf --- /dev/null +++ b/includes/upgrades/class-wcs-repair-2-0.php @@ -0,0 +1,651 @@ += self::time_diff( $subscription['expiry_date'], $subscription['end_date'] ) ) { + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: there are end dates and expiry dates, they are close to each other, setting status to "expired" and returning.', $subscription['order_id'] ) ); + $subscription['status'] = 'expired'; + } else { + // default to cancelled + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: setting the default to "cancelled".', $subscription['order_id'] ) ); + $subscription['status'] = 'cancelled'; + } + self::log_store_owner_review( $subscription ); + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: returning the status with %s', $subscription['order_id'], $subscription['status'] ) ); + return $subscription; + } + + /** + * '_subscription_period': we can attempt to derive this from the time between renewal orders. For example, if there are two renewal + * orders found 3 months apart, the billing period would be month. If there are not two or more renewal orders (we can't use a single + * renewal order because that would account for the free trial) and a _product_id value , if the product still exists, we can use the + * current value set on that product. It won't always be correct, but it's the closest we can get to an accurate estimate. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_period( $subscription, $item_id, $item_meta ) { + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: repairing period for subscription', $subscription['order_id'] ) ); + + // Get info from the product + $subscription = self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'period', '_subscription_period', '' ); + + if ( '' !== $subscription['period'] ) { + return $subscription; + } + + // let's get the renewal orders + $renewal_orders = self::get_renewal_orders( $subscription ); + + if ( count( $renewal_orders ) < 2 ) { + // default to month. Because we're defaulting, we also need to cancel this to avoid charging customers on a schedule they didn't + // agree to. + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: setting default subscription period to month.', $subscription['order_id'] ) ); + self::log_store_owner_review( $subscription ); + $subscription['period'] = 'month'; + $subscription['status'] = 'cancelled'; + return $subscription; + } + + // let's get the last 2 renewal orders + $last_renewal_order = array_shift( $renewal_orders ); + $last_renewal_date = $last_renewal_order->order_date; + $last_renewal_timestamp = strtotime( $last_renewal_date ); + + $second_renewal_order = array_shift( $renewal_orders ); + $second_renewal_date = $second_renewal_order->order_date; + $second_renewal_timestamp = strtotime( $second_renewal_date ); + + $interval = 1; + + // if we have an interval, let's pass this along too, because then it's a known variable + if ( array_key_exists( 'interval', $subscription ) && ! empty( $subscription['interval'] ) ) { + $interval = $subscription['interval']; + } + + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: calling wcs_estimate_period_between().', $subscription['order_id'] ) ); + $period = wcs_estimate_period_between( $last_renewal_date, $second_renewal_date, $interval ); + + // if we have 3 renewal orders, do a double check + if ( ! empty( $renewal_orders ) ) { + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: we have 3 renewal orders, trying to make sure we are right.', $subscription['order_id'] ) ); + + $third_renewal_order = array_shift( $renewal_orders ); + $third_renewal_date = $third_renewal_order->order_date; + + $period2 = wcs_estimate_period_between( $second_renewal_date, $third_renewal_date, $interval ); + + if ( $period == $period2 ) { + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: second check confirmed, we are very confident period is %s.', $subscription['order_id'], $period ) ); + $subscription['period'] = $period; + } + } + + $subscription['period'] = $period; + + return $subscription; + } + + /** + * '_subscription_interval': we can attempt to derive this from the time between renewal orders. For example, if there are two renewal + * orders found 3 months apart, the billing period would be month. If there are not two or more renewal orders (we can't use a single + * renewal order because that would account for the free trial) and a _product_id value , if the product still exists, we can use the + * current value set on that product. It won't always be correct, but it's the closest we can get to an accurate estimate. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_interval( $subscription, $item_id, $item_meta ) { + + // Get info from the product + if ( array_key_exists( '_subscription_interval', $item_meta ) && ! empty( $item_meta['_subscription_interval'] ) ) { + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: getting interval from item meta and returning.', $subscription['order_id'] ) ); + + $subscription['interval'] = $item_meta['_subscription_interval'][0]; + return $subscription; + } + + // by this time we already have a period on our hand + // let's get the renewal orders + $renewal_orders = self::get_renewal_orders( $subscription ); + + if ( count( $renewal_orders ) < 2 ) { + // default to 1 + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: setting default subscription interval to 1.', $subscription['order_id'] ) ); + self::log_store_owner_review( $subscription ); + $subscription['interval'] = 1; + $subscription['status'] = 'cancelled'; + return $subscription; + } + + // let's get the last 2 renewal orders + $last_renewal_order = array_shift( $renewal_orders ); + $last_renewal_date = $last_renewal_order->order_date; + $last_renewal_timestamp = strtotime( $last_renewal_date ); + + $second_renewal_order = array_shift( $renewal_orders ); + $second_renewal_date = $second_renewal_order->order_date; + $second_renewal_timestamp = strtotime( $second_renewal_date ); + + $subscription['interval'] = wcs_estimate_periods_between( $second_renewal_timestamp, $last_renewal_timestamp, $subscription['period'] ); + + return $subscription; + } + + /** + * '_subscription_length': if there are '_subscription_expiry_date' and '_subscription_start_date' values, we can use those to + * determine how many billing periods fall between them, and therefore, the length of the subscription. This data is low value however as + * it is no longer stored in v2.0 and mainly used to determine the expiration date. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_length( $subscription, $item_id, $item_meta ) { + // Let's see if the item meta has that + $subscription = self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'length', '_subscription_length', '' ); + + if ( '' !== $subscription['length'] ) { + return $subscription; + } + + $effective_start_date = self::get_effective_start_date( $subscription ); + + // If we can calculate it from the effective date and expiry date + if ( 'expired' == $subscription['status'] && array_key_exists( 'expiry_date', $subscription ) && ! empty( $subscription['expiry_date'] ) && null !== $effective_start_date && array_key_exists( 'period', $subscription ) && ! empty( $subscription['period'] ) && array_key_exists( 'interval', $subscription ) && ! empty( $subscription['interval'] ) ) { + $intervals = wcs_estimate_periods_between( strtotime( $effective_start_date ), strtotime( $subscription['expiry_date'] ), $subscription['period'], 'floor' ); + $subscription['length'] = $intervals; + } else { + $subscription['length'] = 0; + } + + return $subscription; + } + + /** + * '_subscription_start_date': the original order's '_paid_date' value (stored in post meta) can be used as the subscription's start date. + * If no '_paid_date' exists, because the order used a payment method that doesn't call $order->payment_complete(), like BACs or Cheque, + * then we can use the post_date_gmt column in the wp_posts table of the original order. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_start_date( $subscription, $item_id, $item_meta ) { + global $wpdb; + + $start_date = get_post_meta( $subscription['order_id'], '_paid_date', true ); + + WCS_Upgrade_Logger::add( sprintf( 'Repairing start_date for order %d: Trying to use the _paid date for start date.', $subscription['order_id'] ) ); + + if ( empty( $start_date ) ) { + WCS_Upgrade_Logger::add( '-- start_date from _paid date failed. Using post_date_gmt' ); + + $start_date = $wpdb->get_var( $wpdb->prepare( "SELECT post_date_gmt FROM {$wpdb->posts} WHERE ID = %d", $subscription['order_id'] ) ); + } + + $subscription['start_date'] = $start_date; + return $subscription; + } + + /** + * '_subscription_trial_expiry_date': if the subscription has at least one renewal order, we can set the trial expiration date to the date + * of the first renewal order. However, this is generally safe to default to 0 if it is not set. Especially if the subscription is + * inactive and/or has 1 or more renewals (because its no longer used and is simply for record keeping). + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_trial_expiry_date( $subscription, $item_id, $item_meta ) { + $subscription['trial_expiry_date'] = self::maybe_get_date_from_action_scheduler( 'scheduled_subscription_trial_end', $subscription ); + return $subscription; + } + + /** + * '_subscription_expiry_date': if the subscription has a '_subscription_length' value, that can be used to calculate the expiration date + * (from the '_subscription_start_date' or '_subscription_trial_expiry_date' if one is set). If no length is set, but the subscription has + * an expired status, the '_subscription_end_date' can be used. In most other cases, this is generally safe to default to 0 if the + * subscription is cancelled because its no longer used and is simply for record keeping. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_expiry_date( $subscription, $item_id, $item_meta ) { + $subscription['expiry_date'] = self::maybe_get_date_from_action_scheduler( 'scheduled_subscription_expiration', $subscription ); + return $subscription; + } + + /** + * '_subscription_end_date': if the subscription has a '_subscription_length' value and status of expired, the length can be used to + * calculate the end date as it will be the same as the expiration date. If no length is set, or the subscription has a cancelled status, + * some time within 24 hours after the last renewal order's date can be used to provide a rough estimate. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_end_date( $subscription, $item_id, $item_meta ) { + + $subscription = self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'end_date', '_subscription_end_date', '' ); + + if ( '' !== $subscription['end_date'] ) { + return $subscription; + } + + if ( 'expired' == $subscription['status'] && array_key_exists( 'expiry_date', $subscription ) && ! empty( $subscription['expiry_date'] ) ) { + + $subscription['end_date'] = $subscription['expiry_date']; + + } elseif ( 'cancelled' == $subscription['status'] || ! array_key_exists( 'length', $subscription ) || empty( $subscription['length'] ) ) { + + // get renewal orders + $renewal_orders = self::get_renewal_orders( $subscription ); + $last_order = array_shift( $renewal_orders ); + + if ( empty( $last_order ) ) { + + $subscription['end_date'] = 0; + + } else { + + $subscription['end_date'] = wcs_add_time( 5, 'hours', strtotime( $last_order->order_date ) ); + + } + } else { + + // if everything failed, let's have an empty one + $subscription['end_date'] = 0; + + } + + return $subscription; + } + + /** + * _recurring_line_total': if the subscription has at least one renewal order, this value can be derived from the '_line_total' value of + * that order. If no renewal orders exist, it can be derived roughly by deducting the '_subscription_sign_up_fee' value from the original + * order's total if there is no trial expiration date. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_recurring_line_total( $subscription, $item_id, $item_meta ) { + return self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'recurring_line_total', '_line_total', 0 ); + } + + /** + * _recurring_line_total': if the subscription has at least one renewal order, this value can be derived from the '_line_total' value + * of that order. If no renewal orders exist, it can be derived roughly by deducting the '_subscription_sign_up_fee' value from the + * original order's total if there is no trial expiration date. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_recurring_line_tax( $subscription, $item_id, $item_meta ) { + return self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'recurring_line_tax', '_line_tax', 0 ); + } + + /** + * _recurring_line_total': if the subscription has at least one renewal order, this value can be derived from the '_line_total' value of + * that order. If no renewal orders exist, it can be derived roughly by deducting the '_subscription_sign_up_fee' value from the original + * order's total if there is no trial expiration date + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_recurring_line_subtotal( $subscription, $item_id, $item_meta ) { + return self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'recurring_line_subtotal', '_line_subtotal', 0 ); + } + + /** + * _recurring_line_total': if the subscription has at least one renewal order, this value can be derived from the '_line_total' value of + * that order. If no renewal orders exist, it can be derived roughly by deducting the '_subscription_sign_up_fee' value from the original + * order's total if there is no trial expiration date. + * + * @param array $subscription data about the subscription + * @param numeric $item_id the id of the product we're missing variation id for + * @param array $item_meta meta data about the product + * @return array repaired data about the subscription + */ + public static function repair_recurring_line_subtotal_tax( $subscription, $item_id, $item_meta ) { + return self::repair_from_item_meta( $subscription, $item_id, $item_meta, 'recurring_line_subtotal_tax', '_line_subtotal_tax', 0 ); + } + + /** + * Utility function to calculate the seconds between two timestamps. Order is not important, it's just the difference. + * + * @param string $to mysql timestamp + * @param string $from mysql timestamp + * @return integer number of seconds between the two + */ + private static function time_diff( $to, $from ) { + $to = strtotime( $to ); + $from = strtotime( $from ); + + return abs( $to - $from ); + } + + /** + * Utility function to get all renewal orders in the old structure. + * + * @param array $subscription the sub we're looking for the renewal orders + * @return array of WC_Orders + */ + private static function get_renewal_orders( $subscription ) { + $related_orders = array(); + + $related_post_ids = get_posts( array( + 'posts_per_page' => -1, + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'DESC', + 'post_parent' => $subscription['order_id'], + ) ); + + foreach ( $related_post_ids as $post_id ) { + $related_orders[ $post_id ] = wc_get_order( $post_id ); + } + + return $related_orders; + } + + /** + * Utility method to check the action scheduler for dates + * + * @param string $type the type of scheduled action + * @param string $subscription_key key of subscription in the format of order_id_item_id + * @return string either 0 or mysql date + */ + private static function maybe_get_date_from_action_scheduler( $type, $subscription ) { + $action_args = array( + 'user_id' => intval( $subscription['user_id'] ), + 'subscription_key' => $subscription['subscription_key'], + ); + + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: Repairing date type "%s" from action scheduler...', $subscription['order_id'], $type ) ); + WCS_Upgrade_Logger::add( '-- This is the arguments: ' . PHP_EOL . print_r( array( $action_args, 'hook' => $type ), true ) . PHP_EOL ); + + $next_date_timestamp = wc_next_scheduled_action( $type, $action_args ); + + if ( false === $next_date_timestamp ) { + // set it to 0 as default + $formatted_date = 0; + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: Repairing date type "%s": fetch of date unsuccessfull: no action present. Date is 0.', $subscription['order_id'], $type ) ); + } else { + $formatted_date = date( 'Y-m-d H:i:s', $next_date_timestamp ); + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: Repairing date type "%s": fetch of date successfull. New date is %s', $subscription['order_id'], $type, $formatted_date ) ); + } + + return $formatted_date; + } + + /** + * Utility function to return the effective start date for interval calculations (end of trial period -> start date -> null ) + * + * @param array $subscription subscription data + * @return mixed mysql formatted date, or null if none found + */ + public static function get_effective_start_date( $subscription ) { + + if ( array_key_exists( 'trial_expiry_date', $subscription ) && ! empty( $subscription['trial_expiry_date'] ) ) { + + $effective_date = $subscription['trial_expiry_date']; + + } elseif ( array_key_exists( 'trial_period', $subscription ) && ! empty( $subscription['trial_period'] ) && array_key_exists( 'trial_length', $subscription ) && ! empty( $subscription['trial_length'] ) && array_key_exists( 'start_date', $subscription ) && ! empty( $subscription['start_date'] ) ) { + + // calculate the end of trial from interval, period and start date + $effective_date = date( 'Y-m-d H:i:s', strtotime( '+' . $subscription['trial_length'] . ' ' . $subscription['trial_period'], strtotime( $subscription['start_date'] ) ) ); + + } elseif ( array_key_exists( 'start_date', $subscription ) && ! empty( $subscription['start_date'] ) ) { + + $effective_date = $subscription['start_date']; + + } else { + + $effective_date = null; + + } + + return $effective_date; + } + + + /** + * Logs an entry for the store owner to review an issue. + * + * @param array $subscription subscription data + */ + protected static function log_store_owner_review( $subscription ) { + WCS_Upgrade_Logger::add( sprintf( '-- For order %d: shop owner please review subscription.', $subscription['order_id'] ) ); + } +} diff --git a/includes/upgrades/class-wcs-upgrade-1-2.php b/includes/upgrades/class-wcs-upgrade-1-2.php new file mode 100644 index 0000000..7883885 --- /dev/null +++ b/includes/upgrades/class-wcs-upgrade-1-2.php @@ -0,0 +1,280 @@ +get_col( "SELECT ID FROM $wpdb->posts WHERE post_type = 'shop_order' AND post_parent = 0" ); + + $upgraded_orders = get_option( 'wcs_1_2_upgraded_order_ids', array() ); + + // Transition deprecated subscription status if we aren't in the middle of updating orders + if ( empty( $upgraded_orders ) ) { + $wpdb->query( $wpdb->prepare( "UPDATE $wpdb->usermeta SET meta_value = replace( meta_value, 's:9:\"suspended\"', 's:7:\"on-hold\"' ) WHERE meta_key LIKE %s", '%_woocommerce_subscriptions' ) ); + $wpdb->query( $wpdb->prepare( "UPDATE $wpdb->usermeta SET meta_value = replace( meta_value, 's:6:\"failed\"', 's:9:\"cancelled\"' ) WHERE meta_key LIKE %s", '%_woocommerce_subscriptions' ) ); + } + + $orders_to_upgrade = array_diff( $orders_to_upgrade, $upgraded_orders ); + + // Upgrade all _sign_up_{field} order meta to new order data format + foreach ( $orders_to_upgrade as $order_id ) { + + $order = new WC_Order( $order_id ); + + // Manually check if a product in an order is a subscription, we can't use wcs_order_contains_subscription( $order ) because it relies on the new data structure + $contains_subscription = false; + foreach ( $order->get_items() as $order_item ) { + if ( WC_Subscriptions_Product::is_subscription( WC_Subscriptions_Order::get_items_product_id( $order_item ) ) ) { + $contains_subscription = true; + break; + } + } + + if ( ! $contains_subscription ) { + continue; + } + + $trial_lengths = WC_Subscriptions_Order::get_meta( $order, '_order_subscription_trial_lengths', array() ); + $trial_length = array_pop( $trial_lengths ); + + $has_trial = ( ! empty( $trial_length ) && $trial_length > 0 ) ? true : false ; + + $sign_up_fee_total = WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_total', 0 ); + + // Create recurring_* meta data from existing cart totals + + $cart_discount = $order->get_cart_discount(); + update_post_meta( $order_id, '_order_recurring_discount_cart', $cart_discount ); + + $order_discount = $order->get_order_discount(); + update_post_meta( $order_id, '_order_recurring_discount_total', $order_discount ); + + $order_shipping_tax = get_post_meta( $order_id, '_order_shipping_tax', true ); + update_post_meta( $order_id, '_order_recurring_shipping_tax_total', $order_shipping_tax ); + + $order_tax = get_post_meta( $order_id, '_order_tax', true ); // $order->get_total_tax() includes shipping tax + update_post_meta( $order_id, '_order_recurring_tax_total', $order_tax ); + + $order_total = $order->get_total(); + update_post_meta( $order_id, '_order_recurring_total', $order_total ); + + // Set order totals to include sign up fee fields, if there was a sign up fee on the order and a trial period (other wise, the recurring totals are correct) + if ( $sign_up_fee_total > 0 ) { + + // Order totals need to be changed to be equal to sign up fee totals + if ( $has_trial ) { + + $cart_discount = WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_discount_cart', 0 ); + $order_discount = WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_discount_total', 0 ); + $order_tax = WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_tax_total', 0 ); + $order_total = $sign_up_fee_total; + + } else { // No trial, sign up fees need to be added to order totals + + $cart_discount += WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_discount_cart', 0 ); + $order_discount += WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_discount_total', 0 ); + $order_tax += WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_tax_total', 0 ); + $order_total += $sign_up_fee_total; + + } + + update_post_meta( $order_id, '_order_total', $order_total ); + update_post_meta( $order_id, '_cart_discount', $cart_discount ); + update_post_meta( $order_id, '_order_discount', $order_discount ); + update_post_meta( $order_id, '_order_tax', $order_tax ); + + } + + // Make sure we get order taxes in WC 1.x format + if ( false == WC_Subscriptions_Upgrader::$is_wc_version_2 ) { + + $order_taxes = $order->get_taxes(); + + } else { + + $order_tax_row = $wpdb->get_row( $wpdb->prepare( " + SELECT * FROM {$wpdb->postmeta} + WHERE meta_key = '_order_taxes_old' + AND post_id = %s + ", $order_id ) + ); + + $order_taxes = (array) maybe_unserialize( $order_tax_row->meta_value ); + } + + // Set recurring taxes to order taxes, if using WC 2.0, this will be migrated to the new format in @see WC_Subscriptions_Upgrader::upgrade_to_latest_wc() + update_post_meta( $order_id, '_order_recurring_taxes', $order_taxes ); + + $sign_up_fee_taxes = WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_taxes', array() ); + + // Update order taxes to include sign up fee taxes + foreach ( $sign_up_fee_taxes as $index => $sign_up_tax ) { + + if ( $has_trial && $sign_up_fee_total > 0 ) { // Order taxes need to be set to the same as the sign up fee taxes + + if ( isset( $sign_up_tax['cart_tax'] ) && $sign_up_tax['cart_tax'] > 0 ) { + $order_taxes[ $index ]['cart_tax'] = $sign_up_tax['cart_tax']; + } + } elseif ( ! $has_trial && $sign_up_fee_total > 0 ) { // Sign up fee taxes need to be added to order taxes + + if ( isset( $sign_up_tax['cart_tax'] ) && $sign_up_tax['cart_tax'] > 0 ) { + $order_taxes[ $index ]['cart_tax'] += $sign_up_tax['cart_tax']; + } + } + } + + if ( false == WC_Subscriptions_Upgrader::$is_wc_version_2 ) { // Doing it right: updated Subs *before* updating WooCommerce, the WooCommerce updater will take care of data migration + + update_post_meta( $order_id, '_order_taxes', $order_taxes ); + + } else { // Doing it wrong: updated Subs *after* updating WooCommerce, need to store in WC2.0 tax structure + + $index = 0; + $new_order_taxes = $order->get_taxes(); + + foreach ( $new_order_taxes as $item_id => $order_tax ) { + + $index = $index + 1; + + if ( ! isset( $order_taxes[ $index ]['label'] ) || ! isset( $order_taxes[ $index ]['cart_tax'] ) || ! isset( $order_taxes[ $index ]['shipping_tax'] ) ) { + continue; + } + + // Add line item meta + if ( $item_id ) { + wc_update_order_item_meta( $item_id, 'compound', absint( isset( $order_taxes[ $index ]['compound'] ) ? $order_taxes[ $index ]['compound'] : 0 ) ); + wc_update_order_item_meta( $item_id, 'tax_amount', wc_format_decimal( $order_taxes[ $index ]['cart_tax'] ) ); + wc_update_order_item_meta( $item_id, 'shipping_tax_amount', wc_format_decimal( $order_taxes[ $index ]['shipping_tax'] ) ); + } + } + } + + /* Upgrade each order item to use new Item Meta schema */ + $order_subscription_periods = WC_Subscriptions_Order::get_meta( $order_id, '_order_subscription_periods', array() ); + $order_subscription_intervals = WC_Subscriptions_Order::get_meta( $order_id, '_order_subscription_intervals', array() ); + $order_subscription_lengths = WC_Subscriptions_Order::get_meta( $order_id, '_order_subscription_lengths', array() ); + $order_subscription_trial_lengths = WC_Subscriptions_Order::get_meta( $order_id, '_order_subscription_trial_lengths', array() ); + + $order_items = $order->get_items(); + + foreach ( $order_items as $index => $order_item ) { + + $product_id = WC_Subscriptions_Order::get_items_product_id( $order_item ); + $item_meta = new WC_Order_Item_Meta( $order_item['item_meta'] ); + + $subscription_interval = ( isset( $order_subscription_intervals[ $product_id ] ) ) ? $order_subscription_intervals[ $product_id ] : 1; + $subscription_length = ( isset( $order_subscription_lengths[ $product_id ] ) ) ? $order_subscription_lengths[ $product_id ] : 0; + $subscription_trial_length = ( isset( $order_subscription_trial_lengths[ $product_id ] ) ) ? $order_subscription_trial_lengths[ $product_id ] : 0; + + $subscription_sign_up_fee = WC_Subscriptions_Order::get_meta( $order, '_cart_contents_sign_up_fee_total', 0 ); + + if ( $sign_up_fee_total > 0 ) { + + // Discounted price * Quantity + $sign_up_fee_line_total = WC_Subscriptions_Order::get_meta( $order, '_cart_contents_sign_up_fee_total', 0 ); + $sign_up_fee_line_tax = WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_tax_total', 0 ); + + // Base price * Quantity + $sign_up_fee_line_subtotal = WC_Subscriptions_Order::get_meta( $order, '_cart_contents_sign_up_fee_total', 0 ) + WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_discount_cart', 0 ); + $sign_up_fee_propotion = ( $sign_up_fee_line_total > 0 ) ? $sign_up_fee_line_subtotal / $sign_up_fee_line_total : 0; + $sign_up_fee_line_subtotal_tax = WC_Subscriptions_Manager::get_amount_from_proportion( WC_Subscriptions_Order::get_meta( $order, '_sign_up_fee_tax_total', 0 ), $sign_up_fee_propotion ); + + if ( $has_trial ) { // Set line item totals equal to sign up fee totals + + $order_item['line_subtotal'] = $sign_up_fee_line_subtotal; + $order_item['line_subtotal_tax'] = $sign_up_fee_line_subtotal_tax; + $order_item['line_total'] = $sign_up_fee_line_total; + $order_item['line_tax'] = $sign_up_fee_line_tax; + + } else { // No trial period, sign up fees need to be added to order totals + + $order_item['line_subtotal'] += $sign_up_fee_line_subtotal; + $order_item['line_subtotal_tax'] += $sign_up_fee_line_subtotal_tax; + $order_item['line_total'] += $sign_up_fee_line_total; + $order_item['line_tax'] += $sign_up_fee_line_tax; + + } + } + + // Upgrading with WC 1.x + if ( method_exists( $item_meta, 'add' ) ) { + + $item_meta->add( '_subscription_period', $order_subscription_periods[ $product_id ] ); + $item_meta->add( '_subscription_interval', $subscription_interval ); + $item_meta->add( '_subscription_length', $subscription_length ); + $item_meta->add( '_subscription_trial_length', $subscription_trial_length ); + + $item_meta->add( '_subscription_recurring_amount', $order_item['line_subtotal'] ); // WC_Subscriptions_Product::get_price() would return a price without filters applied + $item_meta->add( '_subscription_sign_up_fee', $subscription_sign_up_fee ); + + // Set recurring amounts for the item + $item_meta->add( '_recurring_line_total', $order_item['line_total'] ); + $item_meta->add( '_recurring_line_tax', $order_item['line_tax'] ); + $item_meta->add( '_recurring_line_subtotal', $order_item['line_subtotal'] ); + $item_meta->add( '_recurring_line_subtotal_tax', $order_item['line_subtotal_tax'] ); + + $order_item['item_meta'] = $item_meta->meta; + + $order_items[ $index ] = $order_item; + + } else { // Ignoring all advice, upgrading 4 months after version 1.2 was released, and doing it with WC 2.0 installed + + wc_add_order_item_meta( $index, '_subscription_period', $order_subscription_periods[ $product_id ] ); + wc_add_order_item_meta( $index, '_subscription_interval', $subscription_interval ); + wc_add_order_item_meta( $index, '_subscription_length', $subscription_length ); + wc_add_order_item_meta( $index, '_subscription_trial_length', $subscription_trial_length ); + wc_add_order_item_meta( $index, '_subscription_trial_period', $order_subscription_periods[ $product_id ] ); + + wc_add_order_item_meta( $index, '_subscription_recurring_amount', $order_item['line_subtotal'] ); + wc_add_order_item_meta( $index, '_subscription_sign_up_fee', $subscription_sign_up_fee ); + + // Calculated recurring amounts for the item + wc_add_order_item_meta( $index, '_recurring_line_total', $order_item['line_total'] ); + wc_add_order_item_meta( $index, '_recurring_line_tax', $order_item['line_tax'] ); + wc_add_order_item_meta( $index, '_recurring_line_subtotal', $order_item['line_subtotal'] ); + wc_add_order_item_meta( $index, '_recurring_line_subtotal_tax', $order_item['line_subtotal_tax'] ); + + if ( $sign_up_fee_total > 0 ) { // Order totals have changed + wc_update_order_item_meta( $index, '_line_subtotal', wc_format_decimalw( $order_item['line_subtotal'] ) ); + wc_update_order_item_meta( $index, '_line_subtotal_tax', wc_format_decimal( $order_item['line_subtotal_tax'] ) ); + wc_update_order_item_meta( $index, '_line_total', wc_format_decimal( $order_item['line_total'] ) ); + wc_update_order_item_meta( $index, '_line_tax', wc_format_decimal( $order_item['line_tax'] ) ); + } + } + } + + // Save the new meta on the order items for WC 1.x (the API functions already saved the data for WC2.x) + if ( false == WC_Subscriptions_Upgrader::$is_wc_version_2 ) { + update_post_meta( $order_id, '_order_items', $order_items ); + } + + $upgraded_orders[] = $order_id; + + update_option( 'wcs_1_2_upgraded_order_ids', $upgraded_orders ); + + } + } +} +WCS_Upgrade_1_2::init(); diff --git a/includes/upgrades/class-wcs-upgrade-1-3.php b/includes/upgrades/class-wcs-upgrade-1-3.php new file mode 100644 index 0000000..b3036dd --- /dev/null +++ b/includes/upgrades/class-wcs-upgrade-1-3.php @@ -0,0 +1,39 @@ +query( " UPDATE $wpdb->options + SET option_name = TRIM(LEADING '_transient_timeout_' FROM option_name) + WHERE option_name LIKE '_transient_timeout_wcs_blocker_%'" ); + + // Change transient keys from the < 1.1.5 format to new format + $wpdb->query( " UPDATE $wpdb->options + SET option_name = CONCAT('wcs_blocker_', TRIM(LEADING '_transient_timeout_block_scheduled_subscription_payments_' FROM option_name)) + WHERE option_name LIKE '_transient_timeout_block_scheduled_subscription_payments_%'" ); + + // Delete old transient values + $wpdb->query( " DELETE FROM $wpdb->options + WHERE option_name LIKE '_transient_wcs_blocker_%' + OR option_name LIKE '_transient_block_scheduled_subscription_payments_%'" ); + } +} +WCS_Upgrade_1_3::init(); diff --git a/includes/upgrades/class-wcs-upgrade-1-4.php b/includes/upgrades/class-wcs-upgrade-1-4.php new file mode 100644 index 0000000..98246f1 --- /dev/null +++ b/includes/upgrades/class-wcs-upgrade-1-4.php @@ -0,0 +1,180 @@ +get_blog_prefix() . 'woocommerce_subscriptions'; + $order_items_table = $wpdb->get_blog_prefix() . 'woocommerce_order_items'; + $order_item_meta_table = $wpdb->get_blog_prefix() . 'woocommerce_order_itemmeta'; + + // Get the IDs of all users who have a subscription + $users_to_upgrade = get_users( array( + 'meta_key' => $subscriptions_meta_key, + 'fields' => 'ID', + 'orderby' => 'ID', + ) + ); + + $users_to_upgrade = array_filter( $users_to_upgrade, __CLASS__ . '::is_user_upgraded' ); + + foreach ( $users_to_upgrade as $user_to_upgrade ) { + + // Can't use WC_Subscriptions_Manager::get_users_subscriptions() because it relies on the new structure + $users_old_subscriptions = get_user_option( $subscriptions_meta_key, $user_to_upgrade ); + + foreach ( $users_old_subscriptions as $subscription_key => $subscription ) { + + if ( ! isset( $subscription['order_id'] ) ) { // Subscription created incorrectly with v1.1.2 + continue; + } + + $order_item_id = WC_Subscriptions_Order::get_item_id_by_subscription_key( $subscription_key ); + + if ( empty( $order_item_id ) ) { // Subscription created incorrectly with v1.1.2 + continue; + } + + if ( ! isset( $subscription['trial_expiry_date'] ) ) { + $subscription['trial_expiry_date'] = ''; + } + + // Set defaults + $failed_payments = isset( $subscription['failed_payments'] ) ? $subscription['failed_payments'] : 0; + $completed_payments = isset( $subscription['completed_payments'] ) ? $subscription['completed_payments'] : array(); + $suspension_count = isset( $subscription['suspension_count'] ) ? $subscription['suspension_count'] : 0; + $trial_expiry_date = isset( $subscription['trial_expiry_date'] ) ? $subscription['trial_expiry_date'] : ''; + + $wpdb->query( + $wpdb->prepare( + "INSERT INTO $order_item_meta_table (order_item_id, meta_key, meta_value) + VALUES + (%d,%s,%s), + (%d,%s,%s), + (%d,%s,%s), + (%d,%s,%s), + (%d,%s,%s), + (%d,%s,%s), + (%d,%s,%s), + (%d,%s,%s)", + $order_item_id, '_subscription_status', $subscription['status'], + $order_item_id, '_subscription_start_date', $subscription['start_date'], + $order_item_id, '_subscription_expiry_date', $subscription['expiry_date'], + $order_item_id, '_subscription_end_date', $subscription['end_date'], + $order_item_id, '_subscription_trial_expiry_date', $trial_expiry_date, + $order_item_id, '_subscription_failed_payments', $failed_payments, + $order_item_id, '_subscription_completed_payments', serialize( $completed_payments ), + $order_item_id, '_subscription_suspension_count', $suspension_count + ) + ); + + } + + update_option( 'wcs_1_4_last_upgraded_user_id', $user_to_upgrade ); + self::$last_upgraded_user_id = $user_to_upgrade; + + } + + // Add an underscore prefix to usermeta key to deprecate, but not delete, subscriptions in user meta + $wpdb->update( + $wpdb->usermeta, + array( 'meta_key' => '_' . $subscriptions_meta_key ), + array( 'meta_key' => $subscriptions_meta_key ) + ); + + // Now set the recurring shipping & payment method on all subscription orders + $wpdb->query( + "INSERT INTO $wpdb->postmeta (`post_id`, `meta_key`, `meta_value`) + SELECT `post_id`, CONCAT('_recurring',`meta_key`), `meta_value` + FROM $wpdb->postmeta + WHERE `meta_key` IN ('_shipping_method','_shipping_method_title','_payment_method','_payment_method_title') + AND `post_id` IN ( + SELECT `post_id` FROM $wpdb->postmeta WHERE `meta_key` = '_order_recurring_total' + )" + ); + + // Set the recurring shipping total on all subscription orders + $wpdb->query( + "INSERT INTO $wpdb->postmeta (`post_id`, `meta_key`, `meta_value`) + SELECT `post_id`, '_order_recurring_shipping_total', `meta_value` + FROM $wpdb->postmeta + WHERE `meta_key` = '_order_shipping' + AND `post_id` IN ( + SELECT `post_id` FROM $wpdb->postmeta WHERE `meta_key` = '_order_recurring_total' + )" + ); + + // Get the ID of all orders for a subscription with a free trial and no sign-up fee + $order_ids = $wpdb->get_col( + "SELECT order_items.order_id FROM $order_items_table AS order_items + LEFT JOIN $order_item_meta_table AS itemmeta USING (order_item_id) + LEFT JOIN $order_item_meta_table AS itemmeta2 USING (order_item_id) + WHERE itemmeta.meta_key = '_subscription_trial_length' + AND itemmeta.meta_value > 0 + AND itemmeta2.meta_key = '_subscription_sign_up_fee' + AND itemmeta2.meta_value > 0" + ); + + $order_ids = implode( ',', array_map( 'absint', array_unique( $order_ids ) ) ); + + // Now set the order totals to $0 (can't use $wpdb->update as it only allows joining WHERE clauses with AND) + if ( ! empty( $order_ids ) ) { + $wpdb->query( + "UPDATE $wpdb->postmeta + SET `meta_value` = 0 + WHERE `meta_key` IN ( '_order_total', '_order_tax', '_order_shipping_tax', '_order_shipping', '_order_discount', '_cart_discount' ) + AND `post_id` IN ( $order_ids )" + ); + + // Now set the line totals to $0 + $wpdb->query( + "UPDATE $order_item_meta_table + SET `meta_value` = 0 + WHERE `meta_key` IN ( '_line_subtotal', '_line_subtotal_tax', '_line_total', '_line_tax', 'tax_amount', 'shipping_tax_amount' ) + AND `order_item_id` IN ( + SELECT `order_item_id` FROM $order_items_table + WHERE `order_item_type` IN ('tax','line_item') + AND `order_id` IN ( $order_ids ) + )" + ); + } + + update_option( 'wcs_1_4_upgraded_order_ids', explode( ',', $order_ids ) ); + } + + /** + * Used to check if a user ID is greater than the last user upgraded to version 1.4. + * + * Needs to be a separate function so that it can use a static variable (and therefore avoid calling get_option() thousands + * of times when iterating over thousands of users). + * + * @since 1.4 + */ + public static function is_user_upgraded( $user_id ) { + + if ( false === self::$last_upgraded_user_id ) { + self::$last_upgraded_user_id = get_option( 'wcs_1_4_last_upgraded_user_id', 0 ); + } + + return ( $user_id > self::$last_upgraded_user_id ) ? true : false; + } +} +WCS_Upgrade_1_4::init(); diff --git a/includes/upgrades/class-wcs-upgrade-1-5.php b/includes/upgrades/class-wcs-upgrade-1-5.php new file mode 100644 index 0000000..d855b57 --- /dev/null +++ b/includes/upgrades/class-wcs-upgrade-1-5.php @@ -0,0 +1,106 @@ +posts} as posts + JOIN {$wpdb->postmeta} as postmeta + ON posts.ID = postmeta.post_id + AND (postmeta.meta_key LIKE '_subscription%') + JOIN {$wpdb->postmeta} AS soldindividually + ON posts.ID = soldindividually.post_id + AND ( soldindividually.meta_key LIKE '_sold_individually' AND soldindividually.meta_value != 'yes' ) + WHERE posts.post_type = 'product'"; + + $subscription_product_ids = $wpdb->get_results( $sql ); + + foreach ( $subscription_product_ids as $product_id ) { + update_post_meta( $product_id->ID, '_sold_individually', 'yes' ); + } + + // Update to new system to limit subscriptions by status rather than in a binary way + $wpdb->query( + "UPDATE $wpdb->postmeta + SET meta_value = 'any' + WHERE meta_key LIKE '_subscription_limit' + AND meta_value LIKE 'yes'" + ); + + return count( $subscription_product_ids ); + } + + /** + * Update subscription WP-Cron tasks to Action Scheduler. + * + * @since 2.0 + */ + public static function upgrade_hooks( $number_hooks_to_upgrade ) { + + $counter = 0; + + $cron = _get_cron_array(); + + foreach ( $cron as $timestamp => $actions ) { + foreach ( $actions as $hook => $details ) { + if ( 'scheduled_subscription_payment' == $hook || 'scheduled_subscription_expiration' == $hook || 'scheduled_subscription_end_of_prepaid_term' == $hook || 'scheduled_subscription_trial_end' == $hook || 'paypal_check_subscription_payment' == $hook ) { + foreach ( $details as $hook_key => $values ) { + + if ( ! wc_next_scheduled_action( $hook, $values['args'] ) ) { + wc_schedule_single_action( $timestamp, $hook, $values['args'] ); + unset( $cron[ $timestamp ][ $hook ][ $hook_key ] ); + $counter++; + } + + if ( $counter >= $number_hooks_to_upgrade ) { + break; + } + } + + // If there are no other jobs scheduled for this hook at this timestamp, remove the entire hook + if ( 0 == count( $cron[ $timestamp ][ $hook ] ) ) { + unset( $cron[ $timestamp ][ $hook ] ); + } + if ( $counter >= $number_hooks_to_upgrade ) { + break; + } + } + } + + // If there are no actions schedued for this timestamp, remove the entire schedule + if ( 0 == count( $cron[ $timestamp ] ) ) { + unset( $cron[ $timestamp ] ); + } + if ( $counter >= $number_hooks_to_upgrade ) { + break; + } + } + + // Set the cron with the removed schedule + _set_cron_array( $cron ); + + return $counter; + } + +} diff --git a/includes/upgrades/class-wcs-upgrade-2-0.php b/includes/upgrades/class-wcs-upgrade-2-0.php new file mode 100644 index 0000000..25a6ae4 --- /dev/null +++ b/includes/upgrades/class-wcs-upgrade-2-0.php @@ -0,0 +1,906 @@ +payment_gateways(); + + WCS_Upgrade_Logger::add( sprintf( 'Upgrading batch of %d subscriptions', $batch_size ) ); + + $upgraded_subscription_count = 0; + + $execution_time_start = time(); + + foreach ( self::get_subscriptions( $batch_size ) as $original_order_item_id => $old_subscription ) { + + try { + + $old_subscription = WCS_Repair_2_0::maybe_repair_subscription( $old_subscription, $original_order_item_id ); + + // don't allow data to be half upgraded on a subscription (but we need the subscription to be the atomic level, not the whole batch, to ensure that resubscribe and switch updates in the same batch have the new subscription available) + $wpdb->query( 'START TRANSACTION' ); + + WCS_Upgrade_Logger::add( sprintf( 'For order %d: beginning subscription upgrade process', $old_subscription['order_id'] ) ); + + $original_order = wc_get_order( $old_subscription['order_id'] ); + + // If we're still in a prepaid term, the new subscription has the new pending cancellation status + if ( 'cancelled' == $old_subscription['status'] && false != wc_next_scheduled_action( 'scheduled_subscription_end_of_prepaid_term', array( 'user_id' => $old_subscription['user_id'], 'subscription_key' => $old_subscription['subscription_key'] ) ) ) { + $subscription_status = 'pending-cancel'; + } elseif ( 'trash' == $old_subscription['status'] ) { + $subscription_status = 'cancelled'; // we'll trash it properly after migrating it + } else { + $subscription_status = $old_subscription['status']; + } + + // Create a new subscription for this user + $new_subscription = wcs_create_subscription( array( + 'status' => $subscription_status, + 'order_id' => $old_subscription['order_id'], + 'customer_id' => $old_subscription['user_id'], + 'start_date' => $old_subscription['start_date'], + 'customer_note' => ( ! empty( $original_order->customer_note ) ) ? $original_order->customer_note : '', + 'billing_period' => $old_subscription['period'], + 'billing_interval' => $old_subscription['interval'], + 'order_version' => ( ! empty( $original_order->order_version ) ) ? $original_order->order_version : '', // Subscriptions will default to WC_Version if $original_order->order_version is not set, but we want the version set at the time of the order + ) ); + + if ( ! is_wp_error( $new_subscription ) ) { + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: post created', $new_subscription->id ) ); + + // Set the order to be manual + if ( isset( $original_order->wcs_requires_manual_renewal ) && 'true' == $original_order->wcs_requires_manual_renewal ) { + $new_subscription->update_manual( true ); + } + + // Add the line item from the order + $subscription_item_id = self::add_product( $new_subscription, $original_order_item_id, wcs_get_order_item( $original_order_item_id, $original_order ) ); + + // Add the line item from the order + self::migrate_download_permissions( $new_subscription, $subscription_item_id, $original_order ); + + // Set dates on the subscription + self::migrate_dates( $new_subscription, $old_subscription ); + + // Set some meta from order meta + self::migrate_post_meta( $new_subscription->id, $original_order ); + + // Copy over order notes which are now logged on the subscription + self::migrate_order_notes( $new_subscription->id, $original_order->id ); + + // Migrate recurring tax, shipping and coupon line items to be plain line items on the subscription + self::migrate_order_items( $new_subscription->id, $original_order->id ); + + // Update renewal orders to link via post meta key instead of post_parent column + self::migrate_renewal_orders( $new_subscription->id, $original_order->id ); + + // Make sure the resubscribe meta data is migrated to use the new subscription ID + meta key + self::migrate_resubscribe_orders( $new_subscription->id, $original_order->id ); + + // If the order for this subscription contains a switch, make sure the switch meta data is migrated to use the new subscription ID + meta key + self::migrate_switch_meta( $new_subscription, $original_order, $subscription_item_id ); + + // If the subscription was in the trash, now that we've set on the meta on it, we need to trash it + if ( 'trash' == $old_subscription['status'] ) { + wp_trash_post( $new_subscription->id ); + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: upgrade complete', $new_subscription->id ) ); + + } else { + + self::deprecate_item_meta( $original_order_item_id ); + + self::deprecate_post_meta( $old_subscription['order_id'] ); + + WCS_Upgrade_Logger::add( sprintf( '!!! For order %d: unable to create subscription. Error: %s', $old_subscription['order_id'], $new_subscription->get_error_message() ) ); + + } + + // If we got here, the batch was upgraded without problems + $wpdb->query( 'COMMIT' ); + + $upgraded_subscription_count++; + + } catch ( Exception $e ) { + + // We can still recover from here. + if ( 422 == $e->getCode() ) { + + self::deprecate_item_meta( $original_order_item_id ); + + self::deprecate_post_meta( $old_subscription['order_id'] ); + + WCS_Upgrade_Logger::add( sprintf( '!!! For order %d: unable to create subscription. Error: %s', $old_subscription['order_id'], $e->getMessage() ) ); + + $wpdb->query( 'COMMIT' ); + + $upgraded_subscription_count++; + + } else { + // we couldn't upgrade this subscription don't commit the query + $wpdb->query( 'ROLLBACK' ); + + throw $e; + } + } + + if ( $upgraded_subscription_count >= $batch_size || ( array_key_exists( 'WPENGINE_ACCOUNT', $_SERVER ) && ( time() - $execution_time_start ) > 50 ) ) { + break; + } + } + + // Double check we actually have no more subscriptions to upgrade as sometimes they can fall through the cracks + if ( $upgraded_subscription_count < $batch_size && $upgraded_subscription_count > 0 && ! array_key_exists( 'WPENGINE_ACCOUNT', $_SERVER ) ) { + $upgraded_subscription_count += self::upgrade_subscriptions( $batch_size ); + } + + WCS_Upgrade_Logger::add( sprintf( 'Upgraded batch of %d subscriptions', $upgraded_subscription_count ) ); + + return $upgraded_subscription_count; + } + + /** + * Gets an array of subscriptions from the v1.5 database structure and returns them in the in the v1.5 structure of + * 'order_item_id' => subscription details array(). + * + * The subscription will be orders from oldest to newest, which is important because self::migrate_resubscribe_orders() + * method expects a subscription to exist in order to migrate the resubscribe meta data correctly. + * + * @param int $batch_size The number of subscriptions to return. + * @return array Subscription details in the v1.5 structure of 'order_item_id' => array() + * @since 2.0 + */ + private static function get_subscriptions( $batch_size ) { + global $wpdb; + + $query = WC_Subscriptions_Upgrader::get_subscription_query( $batch_size ); + + $wpdb->query( 'SET SQL_BIG_SELECTS = 1;' ); + + $raw_subscriptions = $wpdb->get_results( $query ); + + $subscriptions = array(); + + // Create a backward compatible structure + foreach ( $raw_subscriptions as $raw_subscription ) { + + if ( ! isset( $raw_subscription->order_item_id ) ) { + continue; + } + + if ( ! array_key_exists( $raw_subscription->order_item_id, $subscriptions ) ) { + $subscriptions[ $raw_subscription->order_item_id ] = array( + 'order_id' => $raw_subscription->order_id, + 'name' => $raw_subscription->order_item_name, + ); + + $subscriptions[ $raw_subscription->order_item_id ]['user_id'] = (int) get_post_meta( $raw_subscription->order_id, '_customer_user', true ); + } + + $meta_key = str_replace( '_subscription', '', $raw_subscription->meta_key ); + $meta_key = substr( $meta_key, 0, 1 ) == '_' ? substr( $meta_key, 1 ) : $meta_key; + + if ( 'product_id' === $meta_key ) { + $subscriptions[ $raw_subscription->order_item_id ]['subscription_key'] = $subscriptions[ $raw_subscription->order_item_id ]['order_id'] . '_' . $raw_subscription->meta_value; + } + + $subscriptions[ $raw_subscription->order_item_id ][ $meta_key ] = maybe_unserialize( $raw_subscription->meta_value ); + } + + return $subscriptions; + } + + /** + * Add the details of an order item to a subscription as a product line item. + * + * When adding a product to a subscription, we can't use WC_Abstract_Order::add_product() because it requires a product object + * and the details of the product may have changed since it was purchased so we can't simply instantiate an instance of the + * product based on ID. + * + * @param WC_Subscription $new_subscription A subscription object + * @param int $order_item_id ID of the subscription item on the original order + * @param array $order_item An array of order item data in the form returned by WC_Abstract_Order::get_items() + * @return int Subscription $item_id The order item id of the new line item added to the subscription. + * @since 2.0 + */ + private static function add_product( $new_subscription, $order_item_id, $order_item ) { + global $wpdb; + + $item_id = wc_add_order_item( $new_subscription->id, array( + 'order_item_name' => $order_item['name'], + 'order_item_type' => 'line_item', + ) ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new line item ID %d added', $new_subscription->id, $item_id ) ); + + $order_item = WCS_Repair_2_0::maybe_repair_order_item( $order_item ); + + $wpdb->query( $wpdb->prepare( + "INSERT INTO `{$wpdb->prefix}woocommerce_order_itemmeta` (`order_item_id`, `meta_key`, `meta_value`) + VALUES + (%d, '_qty', %s), + (%d, '_tax_class', %s), + (%d, '_product_id', %s), + (%d, '_variation_id', %s), + (%d, '_line_subtotal', %s), + (%d, '_line_total', %s), + (%d, '_line_subtotal_tax', %s), + (%d, '_line_tax', %s)", + // The substitutions + $item_id, $order_item['qty'], + $item_id, $order_item['tax_class'], + $item_id, $order_item['product_id'], + $item_id, $order_item['variation_id'], + $item_id, $order_item['recurring_line_subtotal'], + $item_id, $order_item['recurring_line_total'], + $item_id, $order_item['recurring_line_subtotal_tax'], + $item_id, $order_item['recurring_line_tax'] + ) ); + + // Save tax data array added in WC 2.2 (so it won't exist for all orders/subscriptions) + self::add_line_tax_data( $item_id, $order_item_id, $order_item ); + + if ( isset( $order_item['subscription_trial_length'] ) && $order_item['subscription_trial_length'] > 0 ) { + wc_add_order_item_meta( $item_id, '_has_trial', 'true' ); + } + + // Don't copy item meta already copied + $reserved_item_meta_keys = array( + '_item_meta', + '_qty', + '_tax_class', + '_product_id', + '_variation_id', + '_line_subtotal', + '_line_total', + '_line_tax', + '_line_tax_data', + '_line_subtotal_tax', + ); + + $meta_keys_to_copy = array_diff( array_keys( $order_item['item_meta'] ), array_merge( $reserved_item_meta_keys, self::$subscription_item_meta_keys ) ); + + // Add variation and any other meta + foreach ( $meta_keys_to_copy as $meta_key ) { + foreach ( $order_item['item_meta'][ $meta_key ] as $meta_value ) { + wc_add_order_item_meta( $item_id, $meta_key, $meta_value ); + } + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: for item %d added %s', $new_subscription->id, $item_id, implode( ', ', $meta_keys_to_copy ) ) ); + + // Now that we've copied over the old data, prefix some the subscription meta keys with _wcs_migrated to deprecate it without deleting it (yet) + $rows_affected = self::deprecate_item_meta( $order_item_id ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: %s rows of line item meta deprecated', $new_subscription->id, $rows_affected ) ); + + return $item_id; + } + + /** + * Copy or recreate line tax data to the new subscription. + * + * @param int $new_order_item_id ID of the line item on the new subscription post type + * @param int $old_order_item_id ID of the line item on the original order that in v1.5 represented the subscription + * @param array $order_item The line item on the original order that in v1.5 represented the subscription + * @since 2.0 + */ + public static function add_line_tax_data( $new_order_item_id, $old_order_item_id, $order_item ) { + + // If we have _recurring_line_tax_data, use that + if ( isset( $order_item['item_meta']['_recurring_line_tax_data'] ) ) { + + $line_tax_data = maybe_unserialize( $order_item['item_meta']['_recurring_line_tax_data'][0] ); + $recurring_tax_data = array( 'total' => array(), 'subtotal' => array() ); + $tax_data_keys = array( 'total', 'subtotal' ); + + foreach ( $tax_data_keys as $tax_data_key ) { + foreach ( $line_tax_data[ $tax_data_key ] as $tax_index => $tax_value ) { + $recurring_tax_data[ $tax_data_key ][ $tax_index ] = wc_format_decimal( $tax_value ); + } + } + + // Otherwise try to calculate the recurring values from _line_tax_data + } elseif ( isset( $order_item['item_meta']['_line_tax_data'] ) ) { + + // Copy line tax data if the order doesn't have a '_recurring_line_tax_data' (for backward compatibility) + $line_tax_data = maybe_unserialize( $order_item['item_meta']['_line_tax_data'][0] ); + $line_total = maybe_unserialize( $order_item['item_meta']['_line_total'][0] ); + $recurring_line_total = maybe_unserialize( $order_item['item_meta']['_recurring_line_total'][0] ); + + // There will only be recurring tax data if the recurring amount is > 0 and we can only retroactively calculate recurring amount from initial amount if it is > 0 + if ( $line_total > 0 && $recurring_line_total > 0 ) { + + // Make sure we account for any sign-up fees by determining what proportion of the initial amount the recurring total represents + $recurring_ratio = $recurring_line_total / $line_total; + + $recurring_tax_data = array( 'total' => array(), 'subtotal' => array() ); + $tax_data_keys = array( 'total', 'subtotal' ); + + foreach ( $tax_data_keys as $tax_data_key ) { + foreach ( $line_tax_data[ $tax_data_key ] as $tax_index => $tax_value ) { + + if ( $line_total != $recurring_line_total ) { + // Use total tax amount for both total and subtotal because we don't want any initial discounts to be applied to recurring amounts + $total_tax_amount = $line_tax_data['total'][ $tax_index ]; + } else { + $total_tax_amount = $line_tax_data[ $tax_data_key ][ $tax_index ]; + } + + $recurring_tax_data[ $tax_data_key ][ $tax_index ] = wc_format_decimal( $recurring_ratio * $total_tax_amount ); + } + } + } elseif ( 0 == $line_total && $recurring_line_total > 0 ) { // free trial, we don't have the tax data but we can use 100% of line taxes + + // Can we derive the tax rate key from the line tax data? + if ( ! empty( $line_tax_data ) && ! empty( $line_tax_data['total'] ) ) { + $tax_rate_key = key( $line_tax_data['total'] ); + } else { + // we have no way of knowing what the tax rate key is + $tax_rate_key = 0; + } + + $recurring_tax_data = array( + 'subtotal' => array( $tax_rate_key => $order_item['item_meta']['_recurring_line_subtotal_tax'][0] ), + 'total' => array( $tax_rate_key => $order_item['item_meta']['_recurring_line_tax'][0] ), + ); + } else { + $recurring_tax_data = array( 'total' => array(), 'subtotal' => array() ); + } + } else { + $recurring_tax_data = array( 'total' => array(), 'subtotal' => array() ); + } + + return wc_add_order_item_meta( $new_order_item_id, '_line_tax_data', $recurring_tax_data, true ); + } + + /** + * Deprecate order item meta data stored on the original order that used to make up the subscription by prefixing it with with '_wcs_migrated' + * + * @param int $order_item_id ID of the subscription item on the original order + * @since 2.0 + */ + private static function deprecate_item_meta( $order_item_id ) { + global $wpdb; + + // Now that we've copied over the old data, prefix some the subscription meta keys with _wcs_migrated to deprecate it without deleting it (yet) + $subscription_item_meta_key_string = implode( "','", esc_sql( self::$subscription_item_meta_keys ) ); + + $rows_affected = $wpdb->query( $wpdb->prepare( + "UPDATE `{$wpdb->prefix}woocommerce_order_itemmeta` SET `meta_key` = concat( '_wcs_migrated', `meta_key` ) + WHERE `order_item_id` = %d AND `meta_key` IN ('{$subscription_item_meta_key_string}')", + $order_item_id + ) ); + + return $rows_affected; + } + + /** + * Move download permissions from original order to the new subscription created for the order. + * + * @param WC_Subscription $subscription A subscription object + * @param int $subscription_item_id ID of the product line item on the subscription + * @param WC_Order $original_order The original order that was created to purchase the subscription + * @since 2.0 + */ + private static function migrate_download_permissions( $subscription, $subscription_item_id, $order ) { + global $wpdb; + + $product_id = wcs_get_canonical_product_id( wcs_get_order_item( $subscription_item_id, $subscription ) ); + + $rows_affected = $wpdb->update( + $wpdb->prefix . 'woocommerce_downloadable_product_permissions', + array( + 'order_id' => $subscription->id, + 'order_key' => $subscription->order_key, + ), + array( + 'order_id' => $order->id, + 'order_key' => $order->order_key, + 'product_id' => $product_id, + 'user_id' => absint( $subscription->get_user_id() ), + ), + array( '%d', '%s' ), + array( '%d', '%s', '%d', '%d' ) + ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: migrated %d download permissions for product %d', $subscription->id, $rows_affected, $product_id ) ); + } + + /** + * Migrate the trial expiration, next payment and expiration/end dates to a new subscription. + * + * @since 2.0 + */ + private static function migrate_dates( $new_subscription, $old_subscription ) { + global $wpdb; + + $dates_to_update = array(); + + // old hook => new hook + $date_keys = array( + 'trial_end' => array( + 'old_subscription_key' => 'trial_expiry_date', + 'old_scheduled_hook' => 'scheduled_subscription_trial_end', + ), + 'end' => array( + 'old_subscription_key' => 'expiry_date', + 'old_scheduled_hook' => 'scheduled_subscription_expiration', + ), + 'end_date' => array( + 'old_subscription_key' => '_subscription_end_date', // this is the actual end date, not just the date it was scheduled to expire + 'old_scheduled_hook' => '', + ), + 'next_payment' => array( + 'old_subscription_key' => '', + 'old_scheduled_hook' => 'scheduled_subscription_payment', + ), + 'end_of_prepaid_term' => array( + 'old_subscription_key' => '', + 'old_scheduled_hook' => 'scheduled_subscription_end_of_prepaid_term', + ), + ); + + $old_hook_args = array( + 'user_id' => $old_subscription['user_id'], + 'subscription_key' => $old_subscription['subscription_key'], + ); + + foreach ( $date_keys as $new_key => $old_keys ) { + + // First check if there is a date stored on the subscription, and if so, use that + if ( ! empty( $old_keys['old_subscription_key'] ) && ( isset( $old_subscription[ $old_keys['old_subscription_key'] ] ) && 0 !== $old_subscription[ $old_keys['old_subscription_key'] ] ) ) { + + $dates_to_update[ $new_key ] = $old_subscription[ $old_keys['old_subscription_key'] ]; + + } elseif ( ! empty( $old_keys['old_scheduled_hook'] ) ) { + + // Now check if there is a scheduled date, this is for next payment and end of prepaid term dates + $next_scheduled = wc_next_scheduled_action( $old_keys['old_scheduled_hook'], $old_hook_args ); + + if ( $next_scheduled > 0 ) { + + if ( 'end_of_prepaid_term' == $new_key ) { + wc_schedule_single_action( $next_scheduled, 'woocommerce_scheduled_subscription_end_of_prepaid_term', array( 'subscription_id' => $new_subscription->id ) ); + } else { + $dates_to_update[ $new_key ] = date( 'Y-m-d H:i:s', $next_scheduled ); + } + } + } + } + + // Trash all the hooks in one go to save write requests + $wpdb->update( $wpdb->posts, array( 'post_status' => 'trash' ), array( 'post_type' => ActionScheduler_wpPostStore::POST_TYPE, 'post_content' => wcs_json_encode( $old_hook_args ) ), array( '%s', '%s' ) ); + + $dates_to_update['start'] = $new_subscription->post->post_date_gmt; + + // v2.0 enforces new rules for dates when they are being set, so we need to massage the old data to conform to these new rules + foreach ( $dates_to_update as $date_type => $date ) { + + if ( 0 == $date ) { + continue; + } + + switch ( $date_type ) { + case 'end' : + if ( array_key_exists( 'next_payment', $dates_to_update ) && $date <= $dates_to_update['next_payment'] ) { + $dates_to_update[ $date_type ] = $date; + } + case 'next_payment' : + if ( array_key_exists( 'trial_end', $dates_to_update ) && $date < $dates_to_update['trial_end'] ) { + $dates_to_update[ $date_type ] = $date; + } + case 'trial_end' : + if ( array_key_exists( 'start', $dates_to_update ) && $date <= $dates_to_update['start'] ) { + $dates_to_update[ $date_type ] = $date; + } + } + } + + try { + + if ( ! empty( $dates_to_update ) ) { + $new_subscription->update_dates( $dates_to_update ); + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: updated dates = %s', $new_subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ) ) ); + + } catch ( Exception $e ) { + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: unable to update dates, exception "%s"', $new_subscription->id, $e->getMessage() ) ); + } + } + + /** + * Copy an assortment of meta data from the original order's post meta table to the new subscription's post meta table. + * + * @param int $subscription_id The ID of a 'shop_subscription' post type + * @param WC_Order $order The original order used to purchase a subscription + * @return null + * @since 2.0 + */ + private static function migrate_post_meta( $subscription_id, $order ) { + global $wpdb; + + // Form: new meta key => old meta key + $post_meta_with_new_key = array( + // Order totals + '_order_total' => '_order_recurring_total', + '_order_tax' => '_order_recurring_tax_total', + '_order_shipping' => '_order_recurring_shipping_total', + '_order_shipping_tax' => '_order_recurring_shipping_tax_total', + '_cart_discount' => '_order_recurring_discount_cart', + '_cart_discount_tax' => '_order_recurring_discount_cart_tax', + '_order_discount' => '_order_recurring_discount_total', // deprecated since WC 2.3 + + // Misc meta data + '_payment_method' => '_recurring_payment_method', + '_payment_method_title' => '_recurring_payment_method_title', + '_suspension_count' => '_subscription_suspension_count', + '_contains_synced_subscription' => '_order_contains_synced_subscription', + '_paypal_subscription_id' => 'PayPal Subscriber ID', + ); + + $order_meta = get_post_meta( $order->id ); + + foreach ( $post_meta_with_new_key as $subscription_meta_key => $order_meta_key ) { + + $order_meta_value = get_post_meta( $order->id, $order_meta_key, true ); + + if ( isset( $order_meta[ $order_meta_key ] ) && '' !== $order_meta[ $order_meta_key ] ) { + update_post_meta( $subscription_id, $subscription_meta_key, $order_meta_value ); + } + } + + // Don't copy any of the data we've already copied or known data which isn't relevant to a subscription + $meta_keys_to_ignore = array_merge( array_values( $post_meta_with_new_key ), array_keys( $post_meta_with_new_key ), array( + '_completed_date', + '_customer_ip_address', + '_customer_user_agent', + '_customer_user', + '_order_currency', + '_order_key', + '_paid_date', + '_recorded_sales', + '_transaction_id', + '_transaction_id_original', + '_switched_subscription_first_payment_timestamp', + '_switched_subscription_new_order', + '_switched_subscription_key', + '_old_recurring_payment_method', + '_old_recurring_payment_method_title', + '_wc_points_earned', + '_wcs_requires_manual_renewal', + ) ); + + // Also allow extensions to unset or modify data that will be copied + $order_meta = apply_filters( 'wcs_upgrade_subscription_meta_to_copy', $order_meta, $subscription_id, $order ); + + // Prepare the meta data for a bulk insert + $query_meta_values = array(); + $query_placeholders = array(); + + foreach ( $order_meta as $meta_key => $meta_value ) { + if ( ! in_array( $meta_key, $meta_keys_to_ignore ) ) { + $query_meta_values = array_merge( $query_meta_values, array( + $subscription_id, + $meta_key, + $meta_value[0], + ) ); + $query_placeholders[] = '(%d, %s, %s)'; + } + } + + // Do a single bulk insert instead of using update_post_meta() to massively reduce query time + if ( ! empty( $query_meta_values ) ) { + $rows_affected = $wpdb->query( $wpdb->prepare( + "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) + VALUES " . implode( ', ', $query_placeholders ), + $query_meta_values + ) ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: %d rows of post meta added', $subscription_id, $rows_affected ) ); + } + + // Now that we've copied over the old data, deprecate it + $rows_affected = self::deprecate_post_meta( $order->id ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: %d rows of post meta deprecated', $subscription_id, $rows_affected ) ); + } + + /** + * Deprecate post meta data stored on the original order that used to make up the subscription by prefixing it with with '_wcs_migrated' + * + * @param int $subscription_id The ID of a 'shop_subscription' post type + * @param WC_Order $order The original order used to purchase a subscription + * @return null + * @since 2.0 + */ + private static function deprecate_post_meta( $order_id ) { + global $wpdb; + + $post_meta_to_deprecate = array( + // Order totals + '_order_recurring_total', + '_order_recurring_tax_total', + '_order_recurring_shipping_total', + '_order_recurring_shipping_tax_total', + '_order_recurring_discount_cart', + '_order_recurring_discount_cart_tax', + '_order_recurring_discount_total', + '_recurring_payment_method', + '_recurring_payment_method_title', + '_old_paypal_subscriber_id', + '_old_payment_method', + '_paypal_ipn_tracking_ids', + '_paypal_transaction_ids', + '_paypal_first_ipn_ignored_for_pdt', + '_order_contains_synced_subscription', + '_subscription_suspension_count', + ); + + $post_meta_to_deprecate = implode( "','", esc_sql( $post_meta_to_deprecate ) ); + + $rows_affected = $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET `meta_key` = concat( '_wcs_migrated', `meta_key` ) + WHERE `post_id` = %d AND `meta_key` IN ('{$post_meta_to_deprecate}')", + $order_id + ) ); + + return $rows_affected; + } + + /** + * Migrate order notes relating to subscription events to the new subscription as these are now logged on the subscription + * not the order. + * + * @param int $subscription_id The ID of a 'shop_subscription' post type + * @param WC_Order $order The original order used to purchase a subscription + * @return null + * @since 2.0 + */ + private static function migrate_order_notes( $subscription_id, $order_id ) { + global $wpdb; + + $rows_affected = $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->comments} SET `comment_post_ID` = %d + WHERE `comment_post_id` = %d + AND ( + `comment_content` LIKE '%%subscription%%' + OR `comment_content` LIKE '%%Recurring%%' + OR `comment_content` LIKE '%%Renewal%%' + OR `comment_content` LIKE '%%Simplify payment error%%' + )", + $subscription_id, $order_id + ) ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: migrated %d order notes', $subscription_id, $rows_affected ) ); + } + + /** + * Migrate recurring_tax, recurring_shipping and recurring_coupon line items to be plain tax, shipping and coupon line + * items on a subscription. + * + * @param int $subscription_id The ID of a 'shop_subscription' post type + * @param WC_Order $order The original order used to purchase a subscription + * @return null + * @since 2.0 + */ + private static function migrate_order_items( $subscription_id, $order_id ) { + global $wpdb; + + foreach ( array( 'tax', 'shipping', 'coupon' ) as $line_item_type ) { + $rows_affected = $wpdb->update( + $wpdb->prefix . 'woocommerce_order_items', + array( + 'order_item_type' => $line_item_type, + 'order_id' => $subscription_id, + ), + array( + 'order_item_type' => 'recurring_' . $line_item_type, + 'order_id' => $order_id, + ), + array( '%s', '%d' ), + array( '%s', '%d' ) + ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: migrated %d %s item/s', $subscription_id, $rows_affected, $line_item_type ) ); + } + } + + /** + * The 'post_parent' column is no longer used to relate a renewal order with a subscription/order, instead, we use a + * '_subscription_renewal' post meta value, so the 'post_parent' of all renewal orders needs to be changed from the original + * order's ID, to 0, and then the new subscription's ID should be set as the '_subscription_renewal' post meta value on + * the renewal order. + * + * @param int $subscription_id The ID of a 'shop_subscription' post type + * @param int $order_id The ID of a 'shop_order' which created this susbcription + * @return null + * @since 2.0 + */ + private static function migrate_renewal_orders( $subscription_id, $order_id ) { + global $wpdb; + + // Get the renewal order IDs + $renewal_order_ids = get_posts( array( + 'posts_per_page' => -1, + 'post_status' => 'any', + 'post_type' => 'shop_order', + 'post_parent' => $order_id, + 'fields' => 'ids', + ) ); + + // Set the post meta + foreach ( $renewal_order_ids as $renewal_order_id ) { + update_post_meta( $renewal_order_id, '_subscription_renewal', $subscription_id ); + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: migrated data for renewal orders %s', $subscription_id, implode( ', ', $renewal_order_ids ) ) ); + + $rows_affected = $wpdb->update( + $wpdb->posts, + array( + 'post_parent' => 0, + ), + array( + 'post_parent' => $order_id, + 'post_type' => 'shop_order', + ), + array( '%d' ), + array( '%d', '%s' ) + ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: %d rows of renewal order post_parent values changed', $subscription_id, count( $renewal_order_ids ) ) ); + } + + /** + * The '_original_order' post meta value is no longer used to relate a resubscribe order with a subscription/order, instead, we use + * a '_subscription_resubscribe' post meta value, so the '_original_order' of all resubscribe orders needs to be changed from the + * original order's ID, to 0, and then the new subscription's ID should be set as the '_subscription_resubscribe' post meta value + * on the resubscribe order. + * + * @param int $subscription_id The ID of a 'shop_subscription' post type + * @param int $resubscribe_order_id The ID of a 'shop_order' which created this susbcription + * @return null + * @since 2.0 + */ + private static function migrate_resubscribe_orders( $new_subscription_id, $resubscribe_order_id ) { + global $wpdb; + + // Set the post meta on the new subscription and old order + foreach ( get_post_meta( $resubscribe_order_id, '_original_order', false ) as $original_order_id ) { + + // Because self::get_subscriptions() orders by order ID, it's safe to use wcs_get_subscriptions_for_order() here because the subscription in the new format will have been created for the original order (because its ID will be < the resubscribe order's ID) + foreach ( wcs_get_subscriptions_for_order( $original_order_id ) as $old_subscription ) { + update_post_meta( $resubscribe_order_id, '_subscription_resubscribe', $old_subscription->id, true ); + update_post_meta( $new_subscription_id, '_subscription_resubscribe', $old_subscription->id, true ); + } + + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET `meta_key` = concat( '_wcs_migrated', `meta_key` ) + WHERE `post_id` = %d AND `meta_key` = '_original_order'", + $resubscribe_order_id + ) ); + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: migrated data for resubscribe order %d', $new_subscription_id, $original_order_id ) ); + } + } + + /** + * The '_switched_subscription_key' and '_switched_subscription_new_order' post meta values are no longer used to relate orders + * and switched subscriptions, instead, we need to set a '_subscription_switch' value on the switch order and depreacted the old + * meta keys by prefixing them with '_wcs_migrated'. + * + * Subscriptions also sets a '_switched_subscription_item_id' value on the new line item of for the switched item and a item meta + * value of '_switched_subscription_new_item_id' on the old line item on the subscription, but the old switching process didn't + * change order items, it just created a new order with the new item, so we won't bother setting this as it is purely for record + * keeping. + * + * @param WC_Subscription $new_subscription A subscription object + * @param WC_Order $switch_order The original order used to purchase the subscription + * @param int $subscription_item_id The order item ID of the item added to the subscription by self::add_product() + * @return null + * @since 2.0 + */ + private static function migrate_switch_meta( $new_subscription, $switch_order, $subscription_item_id ) { + global $wpdb; + + // If the order doesn't contain a switch, we don't need to do anything + if ( '' == get_post_meta( $switch_order->id, '_switched_subscription_key', true ) ) { + return; + } + + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET `meta_key` = concat( '_wcs_migrated', `meta_key` ) + WHERE `post_id` = %d AND `meta_key` IN ('_switched_subscription_first_payment_timestamp','_switched_subscription_key')", + $switch_order->id + ) ); + + // Select the orders which had the items which were switched by this order + $previous_order_id = get_posts( array( + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'meta_query' => array( + array( + 'key' => '_switched_subscription_new_order', + 'value' => $switch_order->id, + ), + ), + ) ); + + if ( ! empty( $previous_order_id ) ) { + + $previous_order_id = $previous_order_id[0]; + + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET `meta_key` = concat( '_wcs_migrated', `meta_key` ) + WHERE `post_id` = %d AND `meta_key` = '_switched_subscription_new_order'", + $previous_order_id + ) ); + + // Because self::get_subscriptions() orders by order ID, it's safe to use wcs_get_subscriptions_for_order() here because the subscription in the new format will have been created for the original order (because its ID will be < the switch order's ID) + $old_subscriptions = wcs_get_subscriptions_for_order( $previous_order_id ); + $old_subscription = array_shift( $old_subscriptions ); // there can be only one + + if ( wcs_is_subscription( $old_subscription ) ) { + // Link the old subscription's ID to the switch order using the new switch meta key + update_post_meta( $switch_order->id, '_subscription_switch', $old_subscription->id ); + + // Now store the new/old item IDs for record keeping + foreach ( $old_subscription->get_items() as $item_id => $item ) { + wc_add_order_item_meta( $item_id, '_switched_subscription_new_item_id', $subscription_item_id, true ); + wc_add_order_item_meta( $subscription_item_id, '_switched_subscription_item_id', $item_id, true ); + } + + WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: migrated switch data for subscription %d purchased in order %d', $new_subscription->id, $old_subscription->id, $previous_order_id ) ); + } + } + } +} diff --git a/includes/upgrades/class-wcs-upgrade-logger.php b/includes/upgrades/class-wcs-upgrade-logger.php new file mode 100644 index 0000000..e762324 --- /dev/null +++ b/includes/upgrades/class-wcs-upgrade-logger.php @@ -0,0 +1,64 @@ +add( self::$handle, $message ); + } + + /** + * Clear entries from the upgrade log. + */ + public static function clear() { + if ( empty( self::$log ) ) { + self::$log = new WC_Logger(); + } + self::$log->clear( self::$handle ); + } + + /** + * Schedule a hook to automatically clear the log after 8 weeks + */ + public static function schedule_cleanup() { + $time_to_cleanup = gmdate( 'U' ) + self::$weeks_until_cleanup * WEEK_IN_SECONDS; + self::add( sprintf( 'Upgrade complete. Scheduling log cleanup for %s GMT/UTC', date( 'Y-m-d H:i:s', $time_to_cleanup ) ) ); + wc_schedule_single_action( $time_to_cleanup, 'woocommerce_subscriptions_clear_upgrade_log' ); + } +} +WCS_Upgrade_Logger::init(); diff --git a/includes/upgrades/templates/wcs-about.php b/includes/upgrades/templates/wcs-about.php new file mode 100644 index 0000000..172547e --- /dev/null +++ b/includes/upgrades/templates/wcs-about.php @@ -0,0 +1,196 @@ + + +
    + +

    + +
    + + + +
    + +
    + +
    + +

    + + + + +

    + +
    +

    +
    + +
    +
    + +
    + +
    +

    +

    +

    +

    ', '' ); ?> +

    +
    +
    + +
    + +
    + +
    + +
    +

    +

    +

    tags + printf( esc_html__( 'The new interface is also built on the existing %sEdit Order%s screen. If you\'ve ever modified an order, you already know how to modify a subscription.', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +

    ', '', '', '' ); ?> +

    +
    +
    + +
    +
    + +
    + +
    +

    +

    + tags + printf( esc_html__( 'Your customers can now view the full details of a subscription, including line items, billing and shipping address, billing schedule and renewal orders, from a special %sMy Account > View Subscription%s page.', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +

    +

    + ', '' ); ?> +

    +
    +
    +
    +
    + +
    + +
    + +

    +

    ) tags + printf( esc_html__( 'By default, adding new files to an existing subscription product will automatically provide active subscribers with access to the new files. However, now you can enable a %snew content dripping setting%s to provide subscribers with access to new files only after the next renewal payment.', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +

    ) tags + printf( esc_html__( '%sLearn more »%s', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +
    + +
    + +

    +

    tags + printf( esc_html__( 'For a store manager to change a subscription from automatic to manual renewal payments (or manual to automatic) with Subscriptions v1.5, the database needed to be modified directly. Subscriptions now provides a way for payment gateways to allow you to change that from the new %sEdit Subscription%s interface.', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +

    ) tags + printf( esc_html__( '%sLearn more »%s', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +
    + +
    + +

    +

    tags + printf( esc_html__( 'It was already possible to change a subscription\'s next payment date, but some store managers wanted to provide a customer with an extended free trial or add an extra month to the expiration date. Now you can change all of these dates from the %sEdit Subscription%s screen.', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +

    ) tags + printf( esc_html__( '%sLearn more »%s', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +
    + +
    +
    + +
    +
    +

    +

    ', '' ); ?>

    +
    +
    + +
    + +

    +

    + +
    +
    +

    ', '' ); ?> +

    +

    +

    tags + printf( esc_html__( 'Developers can also now use all the familiar WordPress functions, like %sget_posts()%s, to query or modify subscription data.', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +
    +
    +

    tags + printf( esc_html__( 'New %sWC_Subscription%s Object', 'woocommerce-subscriptions' ), '', '' ); ?> +

    +

    +

    tags, no need to order them + printf( esc_html__( 'Because the %sWC_Subscription%s class extends %sWC_Order%s, you can use its familiar methods, like %s$subscription->update_status()%s or %s$subscription->get_total()%s.', 'woocommerce-subscriptions' ), '', '', '', '', '', '', '', '' ); ?> +

    +
    +
    +

    +

    +

    tags, no need to order them + printf( esc_html__( 'Want to list all the subscriptions on a site? Get %sexample.com/wc-api/v2/subscriptions/%s. Want the details of a specific subscription? Get %s/wc-api/v2/subscriptions//%s.', 'woocommerce-subscriptions' ), '', '', '', '' ); ?> +

    +
    +
    +
    +
    + +
    +
    diff --git a/includes/upgrades/templates/wcs-upgrade-in-progress.php b/includes/upgrades/templates/wcs-upgrade-in-progress.php new file mode 100644 index 0000000..cd270ef --- /dev/null +++ b/includes/upgrades/templates/wcs-upgrade-in-progress.php @@ -0,0 +1,41 @@ + + +> + + + <?php esc_html_e( 'WooCommerce Subscriptions Update in Progress', 'woocommerce-subscriptions' ); ?> + + + + +

    WooCommerce Subscriptions

    +

    +

    +

    +

    +

    + + + + +> + + + <?php esc_html_e( 'WooCommerce Subscriptions Update', 'woocommerce-subscriptions' ); ?> + + + + + + + +

    WooCommerce Subscriptions

    +
    +

    +

    +

    ', '' ); ?> +

    + +

    +

    + +

    + +

    +
    + +
    +
    +
    +

    +

    +

    + 20 ) : ?> +

    + +
      +
    + loading... +

    +
    +
    +

    +

    +

    +

    ' . esc_html( wc_get_log_file_path( WCS_Upgrade_Logger::$handle ) ) . '' ), array( 'code' => array( 'class' => true ) ) ); + ?> +

    +
    +
    +

    +

    +
    + + diff --git a/includes/wcs-cart-functions.php b/includes/wcs-cart-functions.php new file mode 100644 index 0000000..9d11bc9 --- /dev/null +++ b/includes/wcs-cart-functions.php @@ -0,0 +1,354 @@ +get_cart_subtotal(), $cart ) ); +} + +/** + * Get recurring shipping methods. + * + * @access public + */ +function wcs_cart_totals_shipping_html() { + + $initial_packages = WC()->shipping->get_packages(); + + $show_package_details = count( WC()->cart->recurring_carts ) > 1 ? true : false; + $show_package_name = true; + + // Create new subscriptions for each subscription product in the cart (that is not a renewal) + foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { + + // Create shipping packages for each subscription item + if ( WC_Subscriptions_Cart::cart_contains_subscriptions_needing_shipping() && 0 !== $recurring_cart->next_payment_date ) { + + // This will get a package with the 'recurring_cart_key' set to 'none' (because WC_Subscriptions_Cart::display_recurring_totals() set WC_Subscriptions_Cart::$calculation_type to 'recurring_total', but WC_Subscriptions_Cart::$recurring_cart_key has not been set), which ensures that it's a unique package, which we need in order to get all the available packages, not just the package for the recurring cart calculation we completed previously where WC_Subscriptions_Cart::filter_package_rates() removed all unchosen rates and which WC then cached + $packages = $recurring_cart->get_shipping_packages(); + + foreach ( $packages as $i => $base_package ) { + + $product_names = array(); + + $package = WC()->shipping->calculate_shipping_for_package( $base_package ); + + if ( $show_package_details ) { + foreach ( $package['contents'] as $item_id => $values ) { + $product_names[] = $values['data']->get_title() . ' ×' . $values['quantity']; + } + $package_details = implode( ', ', $product_names ); + } else { + $package_details = ''; + } + + $chosen_initial_method = isset( WC()->session->chosen_shipping_methods[ $i ] ) ? WC()->session->chosen_shipping_methods[ $i ] : ''; + $chosen_recurring_method = isset( WC()->session->chosen_shipping_methods[ $recurring_cart_key . '_' . $i ] ) ? WC()->session->chosen_shipping_methods[ $recurring_cart_key . '_' . $i ] : $chosen_initial_method; + + if ( ( 1 === count( $package['rates'] ) ) || ( isset( $initial_packages[ $i ] ) && $package['rates'] == $initial_packages[ $i ]['rates'] && apply_filters( 'wcs_cart_totals_shipping_html_price_only', true, $package, $recurring_cart ) ) ) { + $shipping_method = ( 1 === count( $package['rates'] ) ) ? current( $package['rates'] ) : $package['rates'][ $chosen_initial_method ]; + // packages match, display shipping amounts only + ?> + + label ) ); ?> + + + + + + + + + ' . esc_html( $package_details ) . '

    '; ?> + + + + $package, + 'available_methods' => $package['rates'], + 'show_package_details' => $show_package_details, + 'package_details' => $package_details, + 'package_name' => $package_name, + 'index' => sprintf( '%1$s_%2$d', $recurring_cart_key, $i ), + 'chosen_method' => $chosen_recurring_method, + 'recurring_cart_key' => $recurring_cart_key, + 'recurring_cart' => $recurring_cart, + ), + '', + plugin_dir_path( WC_Subscriptions::$plugin_file ) . 'templates/' + ); + $show_package_name = false; + } + } + } + } +} + +/** + * Display a recurring shipping method's input element, either as a hidden element if there is only one shipping method, + * or a radio select box when there is more than one available method. + * + * @param string $shipping_method_index + * @param object $shipping_method + * @param string $chosen_method + * @param string $input_type + * @return null + */ +function wcs_cart_print_shipping_input( $shipping_method_index, $shipping_method, $chosen_method = '', $input_type = 'hidden' ) { + + if ( 'radio' == $input_type ) { + $checked = checked( $shipping_method->id, $chosen_method, false ); + } else { + // Make sure we only output safe input types + $input_type = 'hidden'; + $checked = ''; + } + + printf( '', + esc_attr( $input_type ), esc_attr( $shipping_method_index ), esc_attr( sanitize_title( $shipping_method->id ) ), esc_attr( $shipping_method->id ), esc_attr( $checked ) ); +} + +/** + * Display a recurring shipping methods price & name as a label + * + * @param object $method + * @return string + */ +function wcs_cart_totals_shipping_method( $method, $cart ) { + + $label = ( method_exists( $method, 'get_label' ) ) ? $method->get_label() : $method->label; // WC < 2.5 compatibility (WC_Shipping_Rate::get_label() was introduced with WC 2.5) + $label .= ': ' . wcs_cart_totals_shipping_method_price_label( $method, $cart ); + + return apply_filters( 'wcs_cart_totals_shipping_method', $label, $method, $cart ); +} + +/** + * Display a recurring shipping methods price + * + * @param object $method + * @return string + */ +function wcs_cart_totals_shipping_method_price_label( $method, $cart ) { + + $price_label = ''; + + if ( $method->cost > 0 ) { + + if ( WC()->cart->tax_display_cart == 'excl' ) { + $price_label .= wcs_cart_price_string( $method->cost, $cart ); + if ( $method->get_shipping_tax() > 0 && $cart->prices_include_tax ) { + $price_label .= ' ' . WC()->countries->ex_tax_or_vat() . ''; + } + } else { + $price_label .= wcs_cart_price_string( $method->cost + $method->get_shipping_tax(), $cart ); + if ( $method->get_shipping_tax() > 0 && ! $cart->prices_include_tax ) { + $price_label .= ' ' . WC()->countries->inc_tax_or_vat() . ''; + } + } + } else { + $price_label .= _x( 'Free', 'shipping method price', 'woocommerce-subscriptions' ); + } + + return $price_label; +} + +/** + * Display recurring taxes total + * + * @access public + * @return void + */ +function wcs_cart_totals_taxes_total_html( $cart ) { + $value = apply_filters( 'woocommerce_cart_totals_taxes_total_html', $cart->get_taxes_total() ); + echo wp_kses_post( apply_filters( 'wcs_cart_totals_taxes_total_html', wcs_cart_price_string( $value, $cart ), $cart ) ); +} + +/** + * Display a recurring coupon's value + * + * @access public + * @param string $coupon + * @return void + */ +function wcs_cart_totals_coupon_html( $coupon, $cart ) { + if ( is_string( $coupon ) ) { + $coupon = new WC_Coupon( $coupon ); + } + + $value = array(); + + if ( $amount = $cart->get_coupon_discount_amount( $coupon->code, $cart->display_cart_ex_tax ) ) { + $discount_html = '-' . wc_price( $amount ); + } else { + $discount_html = ''; + } + + $value[] = apply_filters( 'woocommerce_coupon_discount_amount_html', $discount_html, $coupon ); + + if ( $coupon->enable_free_shipping() ) { + $value[] = __( 'Free shipping coupon', 'woocommerce-subscriptions' ); + } + + // get rid of empty array elements + $value = implode( ', ', array_filter( $value ) ); + + // Apply WooCommerce core filter + $value = apply_filters( 'woocommerce_cart_totals_coupon_html', $value, $coupon ); + + echo wp_kses_post( apply_filters( 'wcs_cart_totals_coupon_html', wcs_cart_price_string( $value, $cart ), $coupon, $cart ) ); +} + +/** + * Get recurring total html including inc tax if needed + * + * @access public + * @return void + */ +function wcs_cart_totals_order_total_html( $cart ) { + $value = '' . $cart->get_total() . ' '; + + // If prices are tax inclusive, show taxes here + if ( wc_tax_enabled() && $cart->tax_display_cart == 'incl' ) { + $tax_string_array = array(); + + if ( get_option( 'woocommerce_tax_total_display' ) == 'itemized' ) { + foreach ( $cart->get_tax_totals() as $code => $tax ) { + $tax_string_array[] = sprintf( '%s %s', $tax->formatted_amount, $tax->label ); + } + } else { + $tax_string_array[] = sprintf( '%s %s', wc_price( $cart->get_taxes_total( true, true ) ), WC()->countries->tax_or_vat() ); + } + + if ( ! empty( $tax_string_array ) ) { + // translators: placeholder is price string, denotes tax included in cart/order total + $value .= '' . sprintf( _x( '(Includes %s)', 'includes tax', 'woocommerce-subscriptions' ), implode( ', ', $tax_string_array ) ) . ''; + } + } + + // Apply WooCommerce core filter + $value = apply_filters( 'woocommerce_cart_totals_order_total_html', $value ); + + echo wp_kses_post( apply_filters( 'wcs_cart_totals_order_total_html', wcs_cart_price_string( $value, $cart ), $cart ) ); +} + +/** + * Return a formatted price string for a given cart object + * + * @access public + * @return void + */ +function wcs_cart_price_string( $recurring_amount, $cart ) { + + return wcs_price_string( apply_filters( 'woocommerce_cart_subscription_string_details', array( + 'recurring_amount' => $recurring_amount, + + // Schedule details + 'subscription_interval' => wcs_cart_pluck( $cart, 'subscription_period_interval' ), + 'subscription_period' => wcs_cart_pluck( $cart, 'subscription_period', '' ), + 'subscription_length' => wcs_cart_pluck( $cart, 'subscription_length' ), + ) ) ); +} + +/** + * Return a given piece of meta data from the cart + * + * The data can exist on the cart object, a cart item, or product data on a cart item. + * The first piece of data with a matching key (in that order) will be returned if it + * is found, otherwise, the value specified with $default, will be returned. + * + * @access public + * @return string + */ +function wcs_cart_pluck( $cart, $field, $default = 0 ) { + + $value = $default; + + if ( isset( $cart->$field ) ) { + $value = $cart->$field; + } else { + foreach ( $cart->get_cart() as $cart_item ) { + if ( isset( $cart_item[ $field ] ) ) { + $value = $cart_item[ $field ]; + } elseif ( $cart_item['data']->$field ) { + $value = $cart_item['data']->$field; + } + } + } + + return $value; +} + +/** + * Append the first renewal payment date to a string (which is the order total HTML string by default) + * + * @access public + * @return string + */ +function wcs_add_cart_first_renewal_payment_date( $order_total_html, $cart ) { + + if ( 0 !== $cart->next_payment_date ) { + $first_renewal_date = date_i18n( wc_date_format(), strtotime( get_date_from_gmt( $cart->next_payment_date ) ) ); + // translators: placeholder is a date + $order_total_html .= '
    ' . sprintf( __( 'First renewal: %s', 'woocommerce-subscriptions' ), $first_renewal_date ) . '
    '; + } + + return $order_total_html; +} +add_filter( 'wcs_cart_totals_order_total_html', 'wcs_add_cart_first_renewal_payment_date', 10, 2 ); + +/** + * Return the cart item name for specific cart item + * + * @access public + * @return string + */ +function wcs_get_cart_item_name( $cart_item, $include = array() ) { + + $include = wp_parse_args( $include, array( + 'attributes' => false, + ) ); + + $cart_item_name = $cart_item['data']->get_title(); + + if ( $include['attributes'] ) { + + $attributes_string = WC()->cart->get_item_data( $cart_item, true ); + $attributes_string = implode( ', ', array_filter( explode( "\n", $attributes_string ) ) ); + + if ( ! empty( $attributes_string ) ) { + $cart_item_name = sprintf( '%s (%s)', $cart_item_name, $attributes_string ); + } + } + + return $cart_item_name; +} diff --git a/includes/wcs-conditional-functions.php b/includes/wcs-conditional-functions.php new file mode 100644 index 0000000..a5d4fe0 --- /dev/null +++ b/includes/wcs-conditional-functions.php @@ -0,0 +1,29 @@ +order->id ) ? $subscription->order->id : $subscription->id; + + // Get an ID to use as the product ID + $subscription_items = $subscription->get_items(); + $first_item = reset( $subscription_items ); + + return $order_id . '_' . WC_Subscriptions_Order::get_items_product_id( $first_item ); +} + +/** + * Return the post ID of a WC_Subscription object for the given subscription key (if one exists). + * + * @param string $subscription_key A subscription key in the deprecated form created by @see WC_Subscriptions_Manager::get_subscription_key() + * @return int|null The post ID for the subscription if it can be found (i.e. an order exists) or null if no order exists for the subscription. + * @since 2.0 + */ +function wcs_get_subscription_id_from_key( $subscription_key ) { + global $wpdb; + + // it can be either 8_13 or just 8. If it's 8, it'll be an integer + if ( ! is_string( $subscription_key ) && ! is_int( $subscription_key ) ) { + return null; + } + + $order_and_product_id = explode( '_', $subscription_key ); + + $subscription_ids = array(); + + // If we have an order ID and product ID, query based on that + if ( ! empty( $order_and_product_id[0] ) && ! empty( $order_and_product_id[1] ) ) { + + $subscription_ids = $wpdb->get_col( $wpdb->prepare( " + SELECT DISTINCT order_items.order_id FROM {$wpdb->prefix}woocommerce_order_items as order_items + LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS itemmeta ON order_items.order_item_id = itemmeta.order_item_id + LEFT JOIN {$wpdb->posts} AS posts ON order_items.order_id = posts.ID + WHERE posts.post_type = 'shop_subscription' + AND posts.post_parent = %d + AND itemmeta.meta_value = %d + AND itemmeta.meta_key IN ( '_variation_id', '_product_id' )", + $order_and_product_id[0], $order_and_product_id[1] ) ); + + } elseif ( ! empty( $order_and_product_id[0] ) ) { + + $subscription_ids = get_posts( array( + 'posts_per_page' => 1, + 'post_parent' => $order_and_product_id[0], + 'post_status' => 'any', + 'post_type' => 'shop_subscription', + 'fields' => 'ids', + ) ); + + } + + return ( ! empty( $subscription_ids ) ) ? $subscription_ids[0] : null; +} + +/** + * Return an instance of a WC_Subscription object for the given subscription key (if one exists). + * + * @param string $subscription_key A subscription key in the deprecated form created by @see self::get_subscription_key() + * @return WC_Subscription|null The subscription object if it can be found (i.e. an order exists) or null if no order exists for the subscription (i.e. it was manually created). + * @since 2.0 + */ +function wcs_get_subscription_from_key( $subscription_key ) { + + $subscription_id = wcs_get_subscription_id_from_key( $subscription_key ); + + if ( null !== $subscription_id && is_numeric( $subscription_id ) ) { + $subscription = wcs_get_subscription( $subscription_id ); + } + + if ( ! is_object( $subscription ) ) { + // translators: placeholder is either subscription key or a subscription id, or, failing that, empty (e.g. "145_21" or "145") + throw new InvalidArgumentException( sprintf( __( 'Could not get subscription. Most likely the subscription key does not refer to a subscription. The key was: "%s".', 'woocommerce-subscriptions' ), $subscription_key ) ); + } + + return $subscription; +} + +/** + * Return an associative array of a given subscriptions details (if it exists) in the pre v2.0 data structure. + * + * @param WC_Subscription $subscription An instance of WC_Subscription + * @return array Subscription details + * @since 2.0 + */ +function wcs_get_subscription_in_deprecated_structure( WC_Subscription $subscription ) { + + $completed_payments = array(); + + if ( $subscription->get_completed_payment_count() ) { + if ( ! empty( $subscription->order ) && $subscription->order->has_status( $subscription->get_paid_order_statuses() ) ) { + $completed_payments[] = $subscription->order->post->post_date_gmt; + } + + $paid_renewal_order_ids = get_posts( array( + 'posts_per_page' => -1, + 'post_status' => $subscription->get_paid_order_statuses(), + 'post_type' => 'shop_order', + 'orderby' => 'date', + 'order' => 'desc', + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => '_subscription_renewal', + 'compare' => '=', + 'value' => $subscription->id, + 'type' => 'numeric', + ), + ), + ) ); + + foreach ( $paid_renewal_order_ids as $paid_renewal_order_id ) { + $completed_payments[] = get_post_field( 'post_date_gmt', $paid_renewal_order_id ); + } + } + + $items = $subscription->get_items(); + $item = array_pop( $items ); + + if ( ! empty( $item ) ) { + + $deprecated_subscription_object = array( + 'order_id' => $subscription->order->id, + 'product_id' => isset( $item['product_id'] ) ? $item['product_id'] : 0, + 'variation_id' => isset( $item['variation_id'] ) ? $item['variation_id'] : 0, + 'status' => $subscription->get_status(), + + // Subscription billing details + 'period' => $subscription->billing_period, + 'interval' => $subscription->billing_interval, + 'length' => wcs_estimate_periods_between( ( 0 == $subscription->get_time( 'trial_end' ) ) ? $subscription->get_time( 'start' ) : $subscription->get_time( 'trial_end' ), $subscription->get_time( 'end' ) + 120, $subscription->billing_period, 'floor' ) / $subscription->billing_interval, // Since subscriptions no longer have a length, we need to calculate the length given the start and end dates and the period. + + // Subscription dates + 'start_date' => $subscription->get_date( 'start' ), + 'expiry_date' => $subscription->get_date( 'end' ), + 'end_date' => $subscription->has_status( wcs_get_subscription_ended_statuses() ) ? $subscription->get_date( 'end' ) : 0, + 'trial_expiry_date' => $subscription->get_date( 'trial_end' ), + + // Payment & status change history + 'failed_payments' => $subscription->failed_payment_count, + 'completed_payments' => $completed_payments, + 'suspension_count' => $subscription->suspension_count, + 'last_payment_date' => $subscription->get_date( 'last_payment' ), + ); + + } else { + + $deprecated_subscription_object = array(); + + } + + return $deprecated_subscription_object; +} diff --git a/includes/wcs-formatting-functions.php b/includes/wcs-formatting-functions.php new file mode 100644 index 0000000..0b82514 --- /dev/null +++ b/includes/wcs-formatting-functions.php @@ -0,0 +1,213 @@ + value pairs for the subscription details to include in the string. Available keys: + * 'initial_amount': The upfront payment for the subscription, including sign up fees, as a string from the @see wc_price(). Default empty string (no initial payment) + * 'initial_description': The word after the initial payment amount to describe the amount. Examples include "now" or "initial payment". Defaults to "up front". + * 'recurring_amount': The amount charged per period. Default 0 (no recurring payment). + * 'subscription_interval': How regularly the subscription payments are charged. Default 1, meaning each period e.g. per month. + * 'subscription_period': The temporal period of the subscription. Should be one of {day|week|month|year} as used by @see wcs_get_subscription_period_strings() + * 'subscription_length': The total number of periods the subscription should continue for. Default 0, meaning continue indefinitely. + * 'trial_length': The total number of periods the subscription trial period should continue for. Default 0, meaning no trial period. + * 'trial_period': The temporal period for the subscription's trial period. Should be one of {day|week|month|year} as used by @see wcs_get_subscription_period_strings() + * 'use_per_slash': Allow calling code to determine if they want the shorter price string using a slash for singular billing intervals, e.g. $5 / month, or the longer form, e.g. $5 every month, which is normally reserved for intervals > 1 + * @since 2.0 + * @return string The price string with translated and billing periods included + */ +function wcs_price_string( $subscription_details ) { + global $wp_locale; + + $subscription_details = wp_parse_args( $subscription_details, array( + 'currency' => '', + 'initial_amount' => '', + 'initial_description' => _x( 'up front', 'initial payment on a subscription', 'woocommerce-subscriptions' ), + 'recurring_amount' => '', + + // Schedule details + 'subscription_interval' => 1, + 'subscription_period' => '', + 'subscription_length' => 0, + 'trial_length' => 0, + 'trial_period' => '', + + // Syncing details + 'is_synced' => false, + 'synchronised_payment_day' => 0, + + // Params for wc_price() + 'display_excluding_tax_label' => false, + + // Params for formatting customisation + 'use_per_slash' => true, + ) + ); + + $subscription_details['subscription_period'] = strtolower( $subscription_details['subscription_period'] ); + + // Make sure prices have been through wc_price() + if ( is_numeric( $subscription_details['initial_amount'] ) ) { + $initial_amount_string = wc_price( $subscription_details['initial_amount'], array( 'currency' => $subscription_details['currency'], 'ex_tax_label' => $subscription_details['display_excluding_tax_label'] ) ); + } else { + $initial_amount_string = $subscription_details['initial_amount']; + } + + if ( is_numeric( $subscription_details['recurring_amount'] ) ) { + $recurring_amount_string = wc_price( $subscription_details['recurring_amount'], array( 'currency' => $subscription_details['currency'], 'ex_tax_label' => $subscription_details['display_excluding_tax_label'] ) ); + } else { + $recurring_amount_string = $subscription_details['recurring_amount']; + } + + $subscription_period_string = wcs_get_subscription_period_strings( $subscription_details['subscription_interval'], $subscription_details['subscription_period'] ); + $subscription_ranges = wcs_get_subscription_ranges(); + + if ( $subscription_details['subscription_length'] > 0 && $subscription_details['subscription_length'] == $subscription_details['subscription_interval'] ) { + if ( ! empty( $subscription_details['initial_amount'] ) ) { + if ( $subscription_details['subscription_interval'] == $subscription_details['subscription_length'] && 0 == $subscription_details['trial_length'] ) { + $subscription_string = $initial_amount_string; + } else { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount string (e.g. "£10 / month" ) + $subscription_string = sprintf( __( '%1$s %2$s then %3$s', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string ); + } + } else { + $subscription_string = $recurring_amount_string; + } + } elseif ( true === $subscription_details['is_synced'] && in_array( $subscription_details['subscription_period'], array( 'week', 'month', 'year' ) ) ) { + // Verbosity is important here to enable translation + $payment_day = $subscription_details['synchronised_payment_day']; + switch ( $subscription_details['subscription_period'] ) { + case 'week': + $payment_day_of_week = WC_Subscriptions_Synchroniser::get_weekday( $payment_day ); + if ( 1 == $subscription_details['subscription_interval'] ) { + if ( ! empty( $subscription_details['initial_amount'] ) ) { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount string, 4$: payment day of the week (e.g. "$15 up front, then $10 every Wednesday") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s every %4$s', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, $payment_day_of_week ); + } else { + // translators: 1$: recurring amount string, 2$: day of the week (e.g. "$10 every Wednesday") + $subscription_string = sprintf( __( '%1$s every %2$s', 'woocommerce-subscriptions' ), $recurring_amount_string, $payment_day_of_week ); + } + } else { + // e.g. $5 every 2 weeks on Wednesday + if ( ! empty( $subscription_details['initial_amount'] ) ) { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front" ), 3$: recurring amount, 4$: interval (e.g. "2nd week"), 5$: day of the week (e.g. "Thursday"); (e.g. "$10 up front, then $20 every 2nd week on Wednesday") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s every %4%s on %5$s', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, wcs_get_subscription_period_strings( $subscription_details['subscription_interval'], $subscription_details['subscription_period'] ), $payment_day_of_week ); + } else { + // translators: 1$: recurring amount string, 2$: period, 3$: day of the week (e.g. "$10 every 2nd week on Wednesday") + $subscription_string = sprintf( __( '%1$s every %2$s on %3$s', 'woocommerce-subscriptions' ), $recurring_amount_string, wcs_get_subscription_period_strings( $subscription_details['subscription_interval'], $subscription_details['subscription_period'] ), $payment_day_of_week ); + } + } + break; + case 'month': + if ( 1 == $subscription_details['subscription_interval'] ) { + // e.g. $15 on the 15th of each month + if ( ! empty( $subscription_details['initial_amount'] ) ) { + if ( $payment_day > 27 ) { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount; (e.g. "$10 up front then $30 on the last day of each month") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s on the last day of each month', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string ); + } else { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount, 4$: day of the month (e.g. "23rd"); (e.g. "$10 up front then $40 on the 23rd of each month") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s on the %4$s of each month', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, WC_Subscriptions::append_numeral_suffix( $payment_day ) ); + } + } else { + if ( $payment_day > 27 ) { + // translators: placeholder is recurring amount + $subscription_string = sprintf( __( '%s on the last day of each month', 'woocommerce-subscriptions' ), $recurring_amount_string ); + } else { + // translators: 1$: recurring amount, 2$: day of the month (e.g. "23rd") (e.g. "$5 every 23rd of each month") + $subscription_string = sprintf( __( '%1$s on the %2$s of each month', 'woocommerce-subscriptions' ), $recurring_amount_string, WC_Subscriptions::append_numeral_suffix( $payment_day ) ); + } + } + } else { + // e.g. $15 on the 15th of every 3rd month + if ( ! empty( $subscription_details['initial_amount'] ) ) { + if ( $payment_day > 27 ) { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount, 4$: interval (e.g. "3rd") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s on the last day of every %4$s month', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, WC_Subscriptions::append_numeral_suffix( $subscription_details['subscription_interval'] ) ); + } else { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount, 4$: day of the month (e.g. "23rd"), 5$: interval (e.g. "3rd") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s on the %4$s day of every %5$s month', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, WC_Subscriptions::append_numeral_suffix( $payment_day ), WC_Subscriptions::append_numeral_suffix( $subscription_details['subscription_interval'] ) ); + } + } else { + if ( $payment_day > 27 ) { + // translators: 1$: recurring amount, 2$: interval (e.g. "3rd") (e.g. "$10 on the last day of every 3rd month") + $subscription_string = sprintf( __( '%1$s on the last day of every %2$s month', 'woocommerce-subscriptions' ), $recurring_amount_string, WC_Subscriptions::append_numeral_suffix( $subscription_details['subscription_interval'] ) ); + } else { + // translators: 1$: recurring amount, 2$: day of the month (e.g. "23rd") (e.g. "$5 every 23rd of each month") + $subscription_string = sprintf( __( '%1$s on the %2$s day of every %3$s month', 'woocommerce-subscriptions' ), $recurring_amount_string, WC_Subscriptions::append_numeral_suffix( $payment_day ), WC_Subscriptions::append_numeral_suffix( $subscription_details['subscription_interval'] ) ); + } + } + } + break; + case 'year': + if ( 1 == $subscription_details['subscription_interval'] ) { + // e.g. $15 on March 15th each year + if ( ! empty( $subscription_details['initial_amount'] ) ) { + // translators: 1$: initial amount, 2$: intial description (e.g. "up front"), 3$: recurring amount, 4$: month of year (e.g. "March"), 5$: day of the month (e.g. "23rd") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s on %4$s %5$s each year', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, $wp_locale->month[ $payment_day['month'] ], WC_Subscriptions::append_numeral_suffix( $payment_day['day'] ) ); + } else { + // translators: 1$: recurring amount, 2$: month (e.g. "March"), 3$: day of the month (e.g. "23rd") (e.g. "$15 on March 15th every 3rd year") + $subscription_string = sprintf( __( '%1$s on %2$s %3$s each year', 'woocommerce-subscriptions' ), $recurring_amount_string, $wp_locale->month[ $payment_day['month'] ], WC_Subscriptions::append_numeral_suffix( $payment_day['day'] ) ); + } + } else { + // e.g. $15 on March 15th every 3rd year + if ( ! empty( $subscription_details['initial_amount'] ) ) { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount, 4$: month (e.g. "March"), 5$: day of the month (e.g. "23rd"), 6$: interval (e.g. "3rd") + $subscription_string = sprintf( __( '%1$s %2$s then %3$s on %4$s %5$s every %6$s year', 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, $wp_locale->month[ $payment_day['month'] ], WC_Subscriptions::append_numeral_suffix( $payment_day['day'] ), WC_Subscriptions::append_numeral_suffix( $subscription_details['subscription_interval'] ) ); + } else { + // translators: 1$: recurring amount, 2$: month (e.g. "March"), 3$: day of the month (e.g. "23rd") (e.g. "$15 on March 15th every 3rd year") + $subscription_string = sprintf( __( '%1$s on %2$s %3$s every %4$s year', 'woocommerce-subscriptions' ), $recurring_amount_string, $wp_locale->month[ $payment_day['month'] ], WC_Subscriptions::append_numeral_suffix( $payment_day['day'] ), WC_Subscriptions::append_numeral_suffix( $subscription_details['subscription_interval'] ) ); + } + } + break; + } + } elseif ( ! empty( $subscription_details['initial_amount'] ) ) { + // translators: 1$: initial amount, 2$: initial description (e.g. "up front"), 3$: recurring amount, 4$: subscription period (e.g. "month" or "3 months") + $subscription_string = sprintf( _n( '%1$s %2$s then %3$s / %4$s', '%1$s %2$s then %3$s every %4$s', $subscription_details['subscription_interval'], 'woocommerce-subscriptions' ), $initial_amount_string, $subscription_details['initial_description'], $recurring_amount_string, $subscription_period_string ); + } elseif ( ! empty( $subscription_details['recurring_amount'] ) || intval( $subscription_details['recurring_amount'] ) === 0 ) { + // translators: 1$: recurring amount, 2$: subscription period (e.g. "month" or "3 months") (e.g. "$15 / month" or "$15 every 2nd month") + if ( true === $subscription_details['use_per_slash'] ) { + $subscription_string = sprintf( _n( '%1$s / %2$s', '%1$s every %2$s', $subscription_details['subscription_interval'], 'woocommerce-subscriptions' ), $recurring_amount_string, $subscription_period_string ); + } else { + $subscription_string = sprintf( __( '%1$s every %2$s', 'woocommerce-subscriptions' ), $recurring_amount_string, $subscription_period_string ); + } + } else { + $subscription_string = ''; + } + + if ( $subscription_details['subscription_length'] > 0 ) { + // translators: 1$: subscription string (e.g. "$10 up front then $5 on March 23rd every 3rd year"), 2$: length (e.g. "4 years") + $subscription_string = sprintf( __( '%1$s for %2$s', 'woocommerce-subscriptions' ), $subscription_string, $subscription_ranges[ $subscription_details['subscription_period'] ][ $subscription_details['subscription_length'] ] ); + } + + if ( $subscription_details['trial_length'] > 0 ) { + $trial_length = wcs_get_subscription_trial_period_strings( $subscription_details['trial_length'], $subscription_details['trial_period'] ); + if ( ! empty( $subscription_details['initial_amount'] ) ) { + // translators: 1$: subscription string (e.g. "$10 up front then $5 on March 23rd every 3rd year"), 2$: trial length (e.g. "3 weeks") + $subscription_string = sprintf( __( '%1$s after %2$s free trial', 'woocommerce-subscriptions' ), $subscription_string, $trial_length ); + } else { + // translators: 1$: trial length (e.g. "3 weeks"), 2$: subscription string (e.g. "$10 up front then $5 on March 23rd every 3rd year") + $subscription_string = sprintf( __( '%1$s free trial then %2$s', 'woocommerce-subscriptions' ), ucfirst( $trial_length ), $subscription_string ); + } + } + + if ( $subscription_details['display_excluding_tax_label'] && wc_tax_enabled() ) { + $subscription_string .= ' ' . WC()->countries->ex_tax_or_vat() . ''; + } + + return apply_filters( 'woocommerce_subscription_price_string', $subscription_string, $subscription_details ); +} diff --git a/includes/wcs-helper-functions.php b/includes/wcs-helper-functions.php new file mode 100644 index 0000000..da90afc --- /dev/null +++ b/includes/wcs-helper-functions.php @@ -0,0 +1,138 @@ + value pairs to customise the input fields + * 'id_attr': (string) the date to display in the selector in MySQL format ('Y-m-d H:i:s'). Required. + * 'date': (string) the date to display in the selector in MySQL format ('Y-m-d H:i:s'). Required. + * 'tab_index': (int) the tab index for the element. Optional. Default 0. + * 'include_time': (bool) whether to include a specific time for the selector. Default true. + * 'include_year': (bool) whether to include a the year field. Default true. + * 'include_buttons': (bool) whether to include submit buttons on the selector. Default true. + * @since 2.0 + */ +function wcs_date_input( $timestamp = 0, $args = array() ) { + + $args = wp_parse_args( $args, array( + 'name_attr' => '', + 'include_time' => true, + ) + ); + + $date = ( 0 !== $timestamp ) ? date_i18n( 'Y-m-d', $timestamp ) : ''; + // translators: date placeholder for input, javascript format + $date_input = ''; + + if ( true === $args['include_time'] ) { + $hours = ( 0 !== $timestamp ) ? date_i18n( 'H', $timestamp ) : ''; + // translators: hour placeholder for time input, javascript format + $hour_input = ''; + $minutes = ( 0 !== $timestamp ) ? date_i18n( 'i', $timestamp ) : ''; + // translators: minute placeholder for time input, javascript format + $minute_input = ''; + $date_input = sprintf( '%s@%s:%s', $date_input, $hour_input, $minute_input ); + } + + $timestamp_utc = ( 0 !== $timestamp ) ? $timestamp - get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS : $timestamp; + $date_input = '
    ' . $date_input . '
    '; + + return apply_filters( 'woocommerce_subscriptions_date_input', $date_input, $timestamp, $args ); +} + +/** + * Get the edit post link without checking if the user can edit that post or not. + * + * @param int $post_id + * @since 2.0 + */ +function wcs_get_edit_post_link( $post_id ) { + $post_type_object = get_post_type_object( get_post_type( $post_id ) ); + + if ( ! $post_type_object || ! in_array( $post_type_object->name, array( 'shop_order', 'shop_subscription' ) ) ) { + return; + } + + return apply_filters( 'get_edit_post_link', admin_url( sprintf( $post_type_object->_edit_link . '&action=edit', $post_id ) ),$post_id, '' ); +} + +/** + * Returns a string with all non-ASCII characters removed. This is useful for any string functions that expect only + * ASCII chars and can't safely handle UTF-8 + * + * Based on the SV_WC_Helper::str_to_ascii() method developed by the masterful SkyVerge team + * + * Note: We must do a strict false check on the iconv() output due to a bug in PHP/glibc {@link https://bugs.php.net/bug.php?id=63450} + * + * @param string $string string to make ASCII + * @return string|null ASCII string or null if error occurred + * @since 2.0 + */ +function wcs_str_to_ascii( $string ) { + + $ascii = false; + + if ( function_exists( 'iconv' ) ) { + $ascii = iconv( 'UTF-8', 'ASCII//IGNORE', $string ); + } + + return false === $ascii ? preg_replace( '/[^a-zA-Z0-9_\-]/', '', $string ) : $ascii; +} + +/** + * wp_json_encode exists since WP 4.1, but because we can't be sure that stores will actually use at least 4.1, we need + * to have this wrapper. + * + * @param array $data Data to be encoded + * + * @return string + */ +function wcs_json_encode( $data ) { + if ( function_exists( 'wp_json_encode' ) ) { + return wp_json_encode( $data ); + } + return json_encode( $data ); +} + +/** + * Inserts a new key/value after the key in the array. + * + * @param $needle The array key to insert the element after + * @param $haystack An array to insert the element into + * @param $new_key The key to insert + * @param $new_value An value to insert + * @return The new array if the $needle key exists, otherwise an unmodified $haystack + */ +function wcs_array_insert_after( $needle, $haystack, $new_key, $new_value) { + + if ( array_key_exists( $needle, $haystack ) ) { + + $new_array = array(); + + foreach ( $haystack as $key => $value ) { + + $new_array[ $key ] = $value; + + if ( $key === $needle ) { + $new_array[ $new_key ] = $new_value; + } + } + + return $new_array; + } + + return $haystack; +} diff --git a/includes/wcs-order-functions.php b/includes/wcs-order-functions.php new file mode 100644 index 0000000..c3e1e8f --- /dev/null +++ b/includes/wcs-order-functions.php @@ -0,0 +1,545 @@ + WC_Subscription form. + * @since 2.0 + */ +function wcs_get_subscriptions_for_order( $order_id, $args = array() ) { + + if ( is_object( $order_id ) ) { + $order_id = $order_id->id; + } + + $args = wp_parse_args( $args, array( + 'order_id' => $order_id, + 'subscriptions_per_page' => -1, + 'order_type' => array( 'parent', 'switch' ), + ) + ); + + // Accept either an array or string (to make it more convenient for singular types, like 'parent' or 'any') + if ( ! is_array( $args['order_type'] ) ) { + $args['order_type'] = array( $args['order_type'] ); + } + + $subscriptions = array(); + $get_all = ( in_array( 'any', $args['order_type'] ) ) ? true : false; + + if ( $order_id && in_array( 'parent', $args['order_type'] ) || $get_all ) { + $subscriptions = wcs_get_subscriptions( $args ); + } + + if ( wcs_order_contains_resubscribe( $order_id ) && ( in_array( 'resubscribe', $args['order_type'] ) || $get_all ) ) { + $subscriptions += wcs_get_subscriptions_for_resubscribe_order( $order_id ); + } + + if ( wcs_order_contains_renewal( $order_id ) && ( in_array( 'renewal', $args['order_type'] ) || $get_all ) ) { + $subscriptions += wcs_get_subscriptions_for_renewal_order( $order_id ); + } + + if ( wcs_order_contains_switch( $order_id ) && ( in_array( 'switch', $args['order_type'] ) || $get_all ) ) { + $subscriptions += wcs_get_subscriptions_for_switch_order( $order_id ); + } + + return $subscriptions; +} + +/** + * Copy the billing, shipping or all addresses from one order to another (including custom order types, like the + * WC_Subscription order type). + * + * @param WC_Order $to_order The WC_Order object to copy the address to. + * @param WC_Order $from_order The WC_Order object to copy the address from. + * @param string $address_type The address type to copy, can be 'shipping', 'billing' or 'all' + * @return WC_Order The WC_Order object with the new address set. + * @since 2.0 + */ +function wcs_copy_order_address( $from_order, $to_order, $address_type = 'all' ) { + + if ( in_array( $address_type, array( 'shipping', 'all' ) ) ) { + $to_order->set_address( array( + 'first_name' => $from_order->shipping_first_name, + 'last_name' => $from_order->shipping_last_name, + 'company' => $from_order->shipping_company, + 'address_1' => $from_order->shipping_address_1, + 'address_2' => $from_order->shipping_address_2, + 'city' => $from_order->shipping_city, + 'state' => $from_order->shipping_state, + 'postcode' => $from_order->shipping_postcode, + 'country' => $from_order->shipping_country, + ), 'shipping' ); + } + + if ( in_array( $address_type, array( 'billing', 'all' ) ) ) { + $to_order->set_address( array( + 'first_name' => $from_order->billing_first_name, + 'last_name' => $from_order->billing_last_name, + 'company' => $from_order->billing_company, + 'address_1' => $from_order->billing_address_1, + 'address_2' => $from_order->billing_address_2, + 'city' => $from_order->billing_city, + 'state' => $from_order->billing_state, + 'postcode' => $from_order->billing_postcode, + 'country' => $from_order->billing_country, + 'email' => $from_order->billing_email, + 'phone' => $from_order->billing_phone, + ), 'billing' ); + } + + return apply_filters( 'woocommerce_subscriptions_copy_order_address', $to_order, $from_order, $address_type ); +} + +/** + * Utility function to copy order meta between two orders. Originally intended to copy meta between + * first order and subscription object, then between subscription and renewal orders. + * + * The hooks used here in those cases are + * - wcs_subscription_meta_query + * - wcs_subscription_meta + * - wcs_renewal_order_meta_query + * - wcs_renewal_order_meta + * + * @param WC_Order $from_order Order to copy meta from + * @param WC_Order $to_order Order to copy meta to + * @param string $type type of copy + */ +function wcs_copy_order_meta( $from_order, $to_order, $type = 'subscription' ) { + global $wpdb; + + if ( ! is_a( $from_order, 'WC_Abstract_Order' ) || ! is_a( $to_order, 'WC_Abstract_Order' ) ) { + throw new InvalidArgumentException( _x( 'Invalid data. Orders expected aren\'t orders.', 'In wcs_copy_order_meta error message. Refers to origin and target order objects.', 'woocommerce-subscriptions' ) ); + } + + if ( ! is_string( $type ) ) { + throw new InvalidArgumentException( _x( 'Invalid data. Type of copy is not a string.', 'Refers to the type of the copy being performed: "copy_order", "subscription", "renewal_order", "resubscribe_order"', 'woocommerce-subscriptions' ) ); + } + + if ( ! in_array( $type, array( 'subscription', 'renewal_order', 'resubscribe_order' ) ) ) { + $type = 'copy_order'; + } + + $meta_query = $wpdb->prepare( + "SELECT `meta_key`, `meta_value` + FROM {$wpdb->postmeta} + WHERE `post_id` = %d + AND `meta_key` NOT LIKE '_schedule_%%' + AND `meta_key` NOT IN ( + '_paid_date', + '_completed_date', + '_order_key', + '_edit_lock', + '_wc_points_earned', + '_transaction_id', + '_billing_interval', + '_billing_period', + '_subscription_resubscribe', + '_subscription_renewal', + '_subscription_switch', + '_payment_method', + '_payment_method_title' + )", + $from_order->id + ); + + if ( 'renewal_order' == $type ) { + $meta_query .= " AND `meta_key` NOT LIKE '_download_permissions_granted' "; + } + + // Allow extensions to add/remove order meta + $meta_query = apply_filters( 'wcs_' . $type . '_meta_query', $meta_query, $to_order, $from_order ); + $meta = $wpdb->get_results( $meta_query, 'ARRAY_A' ); + $meta = apply_filters( 'wcs_' . $type . '_meta', $meta, $to_order, $from_order ); + + foreach ( $meta as $meta_item ) { + update_post_meta( $to_order->id, $meta_item['meta_key'], maybe_unserialize( $meta_item['meta_value'] ) ); + } +} + +/** + * Function to create an order from a subscription. It can be used for a renewal or for a resubscribe + * order creation. It is the common in both of those instances. + * + * @param WC_Subscription|int $subscription Subscription we're basing the order off of + * @param string $type Type of new order. Default values are 'renewal_order'|'resubscribe_order' + * @return WC_Order New order + */ +function wcs_create_order_from_subscription( $subscription, $type ) { + + $type = wcs_validate_new_order_type( $type ); + + if ( is_wp_error( $type ) ) { + return $type; + } + + global $wpdb; + + try { + + $wpdb->query( 'START TRANSACTION' ); + + if ( ! is_object( $subscription ) ) { + $subscription = wcs_get_subscription( $subscription ); + } + + $new_order = wc_create_order( array( + 'customer_id' => $subscription->get_user_id(), + 'customer_note' => $subscription->customer_note, + ) ); + + $new_order->post->post_title = wcs_get_new_order_title( $type ); + + wcs_copy_order_meta( $subscription, $new_order, $type ); + + // Copy over line items and allow extensions to add/remove items or item meta + $items = apply_filters( 'wcs_new_order_items', $subscription->get_items( array( 'line_item', 'fee', 'shipping', 'tax' ) ), $new_order, $subscription ); + $items = apply_filters( 'wcs_' . $type . '_items', $items, $new_order, $subscription ); + + foreach ( $items as $item_index => $item ) { + + $item_name = apply_filters( 'wcs_new_order_item_name', $item['name'], $item, $subscription ); + $item_name = apply_filters( 'wcs_' . $type . '_item_name', $item_name, $item, $subscription ); + + // Create order line item on the renewal order + $recurring_item_id = wc_add_order_item( $new_order->id, array( + 'order_item_name' => $item_name, + 'order_item_type' => $item['type'], + ) ); + + // Remove recurring line items and set item totals based on recurring line totals + foreach ( $item['item_meta'] as $meta_key => $meta_values ) { + foreach ( $meta_values as $meta_value ) { + wc_add_order_item_meta( $recurring_item_id, $meta_key, maybe_unserialize( $meta_value ) ); + } + } + } + + // If we got here, the subscription was created without problems + $wpdb->query( 'COMMIT' ); + + return apply_filters( 'wcs_new_order_created', $new_order, $subscription ); + + } catch ( Exception $e ) { + // There was an error adding the subscription + $wpdb->query( 'ROLLBACK' ); + return new WP_Error( 'new-order-error', $e->getMessage() ); + } +} + +/** + * Function to create a post title based on the type and the current date and time for new orders. By + * default it's either renewal or resubscribe orders. + * + * @param string $type type of new order. By default 'renewal_order'|'resubscribe_order' + * @return string new title for a post + */ +function wcs_get_new_order_title( $type ) { + $type = wcs_validate_new_order_type( $type ); + + $order_date = strftime( _x( '%b %d, %Y @ %I:%M %p', 'Used in subscription post title. "Subscription renewal order - "', 'woocommerce-subscriptions' ) ); + + switch ( $type ) { + case 'renewal_order': + $title = sprintf( __( 'Subscription Renewal Order – %s', 'woocommerce-subscriptions' ), $order_date ); + break; + case 'resubscribe_order': + $title = sprintf( __( 'Resubscribe Order – %s', 'woocommerce-subscriptions' ), $order_date ); + break; + default: + $title = ''; + break; + } + + return apply_filters( 'wcs_new_order_title', $title, $type, $order_date ); +} + +/** + * Utility function to check type. Filterable. Rejects if not in allowed new order types, rejects + * if not actually string. + * + * @param string $type type of new order + * @return string the same type thing if no problems are found + */ +function wcs_validate_new_order_type( $type ) { + if ( ! is_string( $type ) ) { + return new WP_Error( 'order_from_subscription_type_type', sprintf( __( '$type passed to the function was not a string.', 'woocommerce-subscriptions' ), $type ) ); + + } + + if ( ! in_array( $type, apply_filters( 'wcs_new_order_types', array( 'renewal_order', 'resubscribe_order' ) ) ) ) { + return new WP_Error( 'order_from_subscription_type', sprintf( __( '"%s" is not a valid new order type.', 'woocommerce-subscriptions' ), $type ) ); + } + + return $type; +} + +/** + * Wrapper function to get the address from an order / subscription in array format + * @param WC_Order $order The order / subscription we want to get the order from + * @param string $address_type shipping|billing. Default is shipping + * @return array + */ +function wcs_get_order_address( $order, $address_type = 'shipping' ) { + if ( ! is_object( $order ) ) { + return array(); + } + + if ( 'billing' == $address_type ) { + $address = array( + 'first_name' => $order->billing_first_name, + 'last_name' => $order->billing_last_name, + 'company' => $order->billing_company, + 'address_1' => $order->billing_address_1, + 'address_2' => $order->billing_address_2, + 'city' => $order->billing_city, + 'state' => $order->billing_state, + 'postcode' => $order->billing_postcode, + 'country' => $order->billing_country, + 'email' => $order->billing_email, + 'phone' => $order->billing_phone, + ); + } else { + $address = array( + 'first_name' => $order->shipping_first_name, + 'last_name' => $order->shipping_last_name, + 'company' => $order->shipping_company, + 'address_1' => $order->shipping_address_1, + 'address_2' => $order->shipping_address_2, + 'city' => $order->shipping_city, + 'state' => $order->shipping_state, + 'postcode' => $order->shipping_postcode, + 'country' => $order->shipping_country, + ); + } + + return apply_filters( 'wcs_get_order_address', $address, $address_type, $order ); +} + +/** + * Checks an order to see if it contains a subscription. + * + * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in. + * @param array|string $order_type Can include 'parent', 'renewal', 'resubscribe' and/or 'switch'. Defaults to 'parent'. + * @return bool True if the order contains a subscription that belongs to any of the given order types, otherwise false. + * @since 2.0 + */ +function wcs_order_contains_subscription( $order, $order_type = array( 'parent', 'resubscribe', 'switch' ) ) { + + // Accept either an array or string (to make it more convenient for singular types, like 'parent' or 'any') + if ( ! is_array( $order_type ) ) { + $order_type = array( $order_type ); + } + + if ( ! is_object( $order ) ) { + $order = new WC_Order( $order ); + } + + $contains_subscription = false; + $get_all = ( in_array( 'any', $order_type ) ) ? true : false; + + if ( ( in_array( 'parent', $order_type ) || $get_all ) && count( wcs_get_subscriptions_for_order( $order->id, array( 'order_type' => 'parent' ) ) ) > 0 ) { + $contains_subscription = true; + + } elseif ( ( in_array( 'renewal', $order_type ) || $get_all ) && wcs_order_contains_renewal( $order ) ) { + $contains_subscription = true; + + } elseif ( ( in_array( 'resubscribe', $order_type ) || $get_all ) && wcs_order_contains_resubscribe( $order ) ) { + $contains_subscription = true; + + } elseif ( ( in_array( 'switch', $order_type ) || $get_all )&& wcs_order_contains_switch( $order ) ) { + $contains_subscription = true; + + } + + return $contains_subscription; +} + +/** + * A wrapper for getting a specific item from a subscription. + * + * WooCommerce has a wc_add_order_item() function, wc_update_order_item() function and wc_delete_order_item() function, + * but no `wc_get_order_item()` function, so we need to add our own (for now). + * + * @param int $item_id The ID of an order item + * @return WC_Subscription Subscription details in post_id => WC_Subscription form. + * @since 2.0 + */ +function wcs_get_order_item( $item_id, $order ) { + + $item = array(); + + if ( ! is_a( $order, 'WC_Abstract_Order' ) ) { + throw new InvalidArgumentException( __( 'Invalid data. No valid subscription / order was passed in.', 'woocommerce-subscriptions' ), 422 ); + } + + if ( ! absint( $item_id ) ) { + throw new InvalidArgumentException( __( 'Invalid data. No valid item id was passed in.', 'woocommerce-subscriptions' ), 422 ); + } + + foreach ( $order->get_items() as $line_item_id => $line_item ) { + if ( $item_id == $line_item_id ) { + $item = $line_item; + break; + } + } + + return $item; +} + +/** + * Get an instance of WC_Order_Item_Meta for an order item + * + * @param array + * @return WC_Order_Item_Meta + * @since 2.0 + */ +function wcs_get_order_item_meta( $item, $product = null ) { + + if ( WC_Subscriptions::is_woocommerce_pre( '2.4' ) ) { + $item_meta = new WC_Order_Item_Meta( $item['item_meta'], $product ); + } else { + $item_meta = new WC_Order_Item_Meta( $item, $product ); + } + + return $item_meta; +} + +/** + * Create a string representing an order item's name and optionally include attributes. + * + * @param array $order_item An order item. + * @since 2.0 + */ +function wcs_get_order_item_name( $order_item, $include = array() ) { + + $include = wp_parse_args( $include, array( + 'attributes' => false, + ) ); + + $order_item_name = $order_item['name']; + + if ( $include['attributes'] && ! empty( $order_item['item_meta'] ) ) { + + $attribute_strings = array(); + + foreach ( $order_item['item_meta'] as $meta_key => $meta_value ) { + + $meta_value = $meta_value[0]; + + // Skip hidden core fields + if ( in_array( $meta_key, apply_filters( 'woocommerce_hidden_order_itemmeta', array( + '_qty', + '_tax_class', + '_product_id', + '_variation_id', + '_line_subtotal', + '_line_subtotal_tax', + '_line_total', + '_line_tax', + '_switched_subscription_item_id', + ) ) ) ) { + continue; + } + + // Skip serialised meta + if ( is_serialized( $meta_value ) ) { + continue; + } + + // Get attribute data + if ( taxonomy_exists( wc_sanitize_taxonomy_name( $meta_key ) ) ) { + $term = get_term_by( 'slug', $meta_value, wc_sanitize_taxonomy_name( $meta_key ) ); + $meta_key = wc_attribute_label( wc_sanitize_taxonomy_name( $meta_key ) ); + $meta_value = isset( $term->name ) ? $term->name : $meta_value; + } else { + $meta_key = apply_filters( 'woocommerce_attribute_label', wc_attribute_label( $meta_key ), $meta_key ); + } + + $attribute_strings[] = sprintf( '%s: %s', wp_kses_post( rawurldecode( $meta_key ) ), wp_kses_post( rawurldecode( $meta_value ) ) ); + } + + $order_item_name = sprintf( '%s (%s)', $order_item_name, implode( ', ', $attribute_strings ) ); + } + + return apply_filters( 'wcs_get_order_item_name', $order_item_name, $order_item, $include ); +} + +/** + * Get the full name for a order/subscription line item, including the items non hidden meta + * (i.e. attributes), as a flat string. + * + * @param array + * @return string + */ +function wcs_get_line_item_name( $line_item ) { + + $item_meta_strings = array(); + + foreach ( $line_item['item_meta'] as $meta_key => $meta_value ) { + + $meta_value = $meta_value[0]; + + // Skip hidden core fields + if ( in_array( $meta_key, apply_filters( 'woocommerce_hidden_order_itemmeta', array( + '_qty', + '_tax_class', + '_product_id', + '_variation_id', + '_line_subtotal', + '_line_subtotal_tax', + '_line_total', + '_line_tax', + '_line_tax_data', + ) ) ) ) { + continue; + } + + // Skip serialised meta + if ( is_serialized( $meta_value ) ) { + continue; + } + + // Get attribute data + if ( taxonomy_exists( wc_sanitize_taxonomy_name( $meta_key ) ) ) { + $term = get_term_by( 'slug', $meta_value, wc_sanitize_taxonomy_name( $meta_key ) ); + $meta_key = wc_attribute_label( wc_sanitize_taxonomy_name( $meta_key ) ); + $meta_value = isset( $term->name ) ? $term->name : $meta_value; + } else { + $meta_key = apply_filters( 'woocommerce_attribute_label', wc_attribute_label( $meta_key ), $meta_key ); + } + + $item_meta_strings[] = sprintf( '%s: %s', rawurldecode( $meta_key ), rawurldecode( $meta_value ) ); + } + + if ( ! empty( $item_meta_strings ) ) { + $line_item_name = sprintf( '%s (%s)', $line_item['name'], implode( ', ', $item_meta_strings ) ); + } else { + $line_item_name = $line_item['name']; + } + + return apply_filters( 'wcs_line_item_name', $line_item_name, $line_item ); +} diff --git a/includes/wcs-renewal-functions.php b/includes/wcs-renewal-functions.php new file mode 100644 index 0000000..3ed3236 --- /dev/null +++ b/includes/wcs-renewal-functions.php @@ -0,0 +1,128 @@ +get_error_message() ); + } + + update_post_meta( $renewal_order->id, '_subscription_renewal', $subscription->id ); + + return apply_filters( 'wcs_renewal_order_created', $renewal_order, $subscription ); +} + +/** + * Check if a given order is a subscription renewal order. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 2.0 + */ +function wcs_order_contains_renewal( $order ) { + + if ( ! is_object( $order ) ) { + $order = wc_get_order( $order ); + } + + if ( 'simple' == $order->order_type && isset( $order->subscription_renewal ) && $order->subscription_renewal > 0 ) { // It's a parent order or original order + $is_renewal = true; + } else { + $is_renewal = false; + } + + return apply_filters( 'woocommerce_subscriptions_is_renewal_order', $is_renewal, $order ); +} + +/** + * Checks the cart to see if it contains a subscription product renewal. + * + * @param bool | Array The cart item containing the renewal, else false. + * @return string + * @since 2.0 + */ +function wcs_cart_contains_renewal() { + + $contains_renewal = false; + + if ( ! empty( WC()->cart->cart_contents ) ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['subscription_renewal'] ) ) { + $contains_renewal = $cart_item; + break; + } + } + } + + return apply_filters( 'wcs_cart_contains_renewal', $contains_renewal ); +} + +/** + * Checks the cart to see if it contains a subscription product renewal for a failed renewal payment. + * + * @param bool | Array The cart item containing the renewal, else false. + * @return string + * @since 2.0 + */ +function wcs_cart_contains_failed_renewal_order_payment() { + + $contains_renewal = false; + $cart_item = wcs_cart_contains_renewal(); + + if ( false !== $cart_item && isset( $cart_item['subscription_renewal']['renewal_order_id'] ) ) { + $renewal_order = wc_get_order( $cart_item['subscription_renewal']['renewal_order_id'] ); + if ( $renewal_order->has_status( 'failed' ) ) { + $contains_renewal = $cart_item; + } + } + + return apply_filters( 'wcs_cart_contains_failed_renewal_order_payment', $contains_renewal ); +} + +/** + * Get the subscription to which a renewal order relates. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 2.0 + */ +function wcs_get_subscriptions_for_renewal_order( $order ) { + + if ( ! is_object( $order ) ) { + $order = wc_get_order( $order ); + } + + $subscriptions = array(); + $subscription_ids = get_post_meta( $order->id, '_subscription_renewal', false ); + + foreach ( $subscription_ids as $subscription_id ) { + if ( wcs_is_subscription( $subscription_id ) ) { + $subscriptions[ $subscription_id ] = wcs_get_subscription( $subscription_id ); + } + } + + return apply_filters( 'wcs_subscriptions_for_renewal_order', $subscriptions, $order ); +} diff --git a/includes/wcs-resubscribe-functions.php b/includes/wcs-resubscribe-functions.php new file mode 100644 index 0000000..63798a2 --- /dev/null +++ b/includes/wcs-resubscribe-functions.php @@ -0,0 +1,228 @@ +id, '_subscription_resubscribe', true ) ) { + $is_resubscribe_order = true; + } else { + $is_resubscribe_order = false; + } + + return apply_filters( 'woocommerce_subscriptions_is_resubscribe_order', $is_resubscribe_order, $order ); +} + +/** + * Create a resubscribe order to record a customer resubscribing to an expired or cancelled subscription. + * + * This method is a wrapper for @see wcs_create_order() which creates an order with the same post meta, order + * items and order item meta as the subscription passed to it. No trial periods or sign up fees are applied + * to resubscribe orders. + * + * @param int | WC_Subscription $subscription Post ID of a 'shop_subscription' post, or instance of a WC_Subscription object + * @return WC_Subscription + * @since 2.0 + */ +function wcs_create_resubscribe_order( $subscription ) { + + $resubscribe_order = wcs_create_order_from_subscription( $subscription, 'resubscribe_order' ); + + if ( is_wp_error( $resubscribe_order ) ) { + return new WP_Error( 'resubscribe-order-error', $renewal_order->get_error_message() ); + } + + // Keep a record of the original subscription's ID on the new order + update_post_meta( $resubscribe_order->id, '_subscription_resubscribe', $subscription->id, true ); + + do_action( 'wcs_resubscribe_order_created', $resubscribe_order, $subscription ); + + return $resubscribe_order; +} + +/** + * Returns a URL including required parameters for an authenticated user to renew a subscription + * + * @param int | WC_Subscription $subscription Post ID of a 'shop_subscription' post, or instance of a WC_Subscription object + * @return string + * @since 2.0 + */ +function wcs_get_users_resubscribe_link( $subscription ) { + + $subscription_id = ( is_object( $subscription ) ) ? $subscription->id : $subscription; + + $resubscribe_link = add_query_arg( array( 'resubscribe' => $subscription_id ), get_permalink( wc_get_page_id( 'myaccount' ) ) ); + $resubscribe_link = wp_nonce_url( $resubscribe_link, $subscription_id ); + + return apply_filters( 'wcs_users_resubscribe_link', $resubscribe_link, $subscription_id ); +} + +/** + * Returns a URL including required parameters for an authenticated user to renew a subscription by product ID. + * + * @param int $product_id The ID of a product post type. + * @since 1.2 + */ +function wcs_get_users_resubscribe_link_for_product( $product_id ) { + + $renewal_url = ''; + + if ( is_user_logged_in() ) { + foreach ( wcs_get_users_subscriptions() as $subscription ) { + foreach ( $subscription->get_items() as $line_item ) { + if ( ( $line_item['product_id'] == $product_id || $line_item['variation_id'] == $product_id ) && wcs_can_user_resubscribe_to( $subscription ) ) { + $renewal_url = wcs_get_users_resubscribe_link( $subscription ); + break; + } + } + } + } + + return apply_filters( 'wcs_users_resubscribe_link_for_product', $renewal_url, $product_id ); +} + +/** + * Checks the cart to see if it contains a subscription product renewal. + * + * @param bool | Array The cart item containing the renewal, else false. + * @return string + * @since 2.0 + */ +function wcs_cart_contains_resubscribe( $cart = '' ) { + + $contains_resubscribe = false; + + if ( empty( $cart ) ) { + $cart = WC()->cart; + } + + if ( ! empty( $cart->cart_contents ) ) { + foreach ( $cart->cart_contents as $cart_item ) { + if ( isset( $cart_item['subscription_resubscribe'] ) ) { + $contains_resubscribe = $cart_item; + break; + } + } + } + + return apply_filters( 'wcs_cart_contains_resubscribe', $contains_resubscribe, $cart ); +} + +/** + * Get the subscription to which a renewal order relates. + * + * @param WC_Order|int $order The WC_Order object or ID of a WC_Order order. + * @since 2.0 + */ +function wcs_get_subscriptions_for_resubscribe_order( $order ) { + + if ( ! is_object( $order ) ) { + $order = wc_get_order( $order ); + } + + $subscriptions = array(); + $subscription_ids = get_post_meta( $order->id, '_subscription_resubscribe', false ); + + foreach ( $subscription_ids as $subscription_id ) { + if ( wcs_is_subscription( $subscription_id ) ) { + $subscriptions[ $subscription_id ] = wcs_get_subscription( $subscription_id ); + } + } + + return apply_filters( 'wcs_subscriptions_for_resubscribe_order', $subscriptions, $order ); +} + +/** + * Check if a user can resubscribe to an expired or cancelled subscription by creating a + * new subscription with the same terms. + * + * For it to be possible to resubscribe to a subscription, the user specified with $user_id must + * and the subscription must: + * 1. be be inactive (expired or cancelled) + * 2. had at least one payment, to avoid circumventing sign-up fees + * 3. its parent order must not have already been superseded by a new order (to prevent + * displaying "Resubscribe" links on subscriptions that have already been renewed) + * 4. the products to which the subscription relates must not have been deleted + * + * @param int | WC_Subscription $subscription Post ID of a 'shop_subscription' post, or instance of a WC_Subscription object + * @param int The ID of a user + * @return bool + * @since 2.0 + */ +function wcs_can_user_resubscribe_to( $subscription, $user_id = '' ) { + + if ( ! is_object( $subscription ) ) { + $subscription = wcs_get_subscription( $subscription ); + } + + if ( empty( $user_id ) ) { + $user_id = get_current_user_id(); + } + + if ( empty( $subscription ) ) { + + $can_user_resubscribe = false; + + } elseif ( ! user_can( $user_id, 'subscribe_again', $subscription->id ) ) { + + $can_user_resubscribe = false; + + } else { + + $resubscribe_orders = get_posts( array( + 'meta_query' => array( + array( + 'key' => '_subscription_resubscribe', + 'compare' => '=', + 'value' => $subscription->id, + 'type' => 'numeric', + ), + ), + 'post_type' => 'shop_order', + 'post_status' => 'any', + ) ); + + // Make sure all line items still exist + $all_line_items_exist = true; + + foreach ( $subscription->get_items() as $line_item ) { + + $product = ( ! empty( $line_item['variation_id'] ) ) ? wc_get_product( $line_item['variation_id'] ) : wc_get_product( $line_item['product_id'] ); + + if ( false === $product ) { + $all_line_items_exist = false; + break; + } + } + + if ( empty( $resubscribe_orders ) && $subscription->get_completed_payment_count() > 0 && $subscription->get_total() > 0 && true === $all_line_items_exist && $subscription->has_status( array( 'cancelled', 'expired', 'trash' ) ) ) { + $can_user_resubscribe = true; + } else { + $can_user_resubscribe = false; + } + } + + return apply_filters( 'wcs_can_user_resubscribe_to_subscription', $can_user_resubscribe, $subscription, $user_id ); +} diff --git a/includes/wcs-switch-functions.php b/includes/wcs-switch-functions.php new file mode 100644 index 0000000..793424a --- /dev/null +++ b/includes/wcs-switch-functions.php @@ -0,0 +1,147 @@ +order_type || isset( $order->subscription_renewal ) ) { // It's a parent order or renewal order + + $is_switch_order = false; + + } else { + + $subscription_ids = get_post_meta( $order->id, '_subscription_switch', false ); + + if ( ! empty( $subscription_ids ) ) { + $is_switch_order = true; + } else { + $is_switch_order = false; + } + } + + return apply_filters( 'woocommerce_subscriptions_is_switch_order', $is_switch_order, $order ); +} + +/** + * Get the subscriptions that had an item switch for a given order (if any). + * + * @param int|WC_Order $order_id The post_id of a shop_order post or an instance of a WC_Order object + * @return array Subscription details in post_id => WC_Subscription form. + * @since 2.0 + */ +function wcs_get_subscriptions_for_switch_order( $order_id ) { + + if ( is_object( $order_id ) ) { + $order_id = $order_id->id; + } + + $subscriptions = array(); + $subscription_ids = get_post_meta( $order_id, '_subscription_switch', false ); + + foreach ( $subscription_ids as $subscription_id ) { + $subscriptions[ $subscription_id ] = wcs_get_subscription( $subscription_id ); + } + + return $subscriptions; +} + +/** + * Get all the orders which have recorded a switch for a given subscription. + * + * @param int|WC_Subscription $subscription_id The post_id of a shop_subscription post or an instance of a WC_Subscription object + * @return array Order details in post_id => WC_Order form. + * @since 2.0 + */ +function wcs_get_switch_orders_for_subscription( $subscription_id ) { + + $orders = array(); + + // Select the orders which switched item/s from this subscription + $order_ids = get_posts( array( + 'post_type' => 'shop_order', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'meta_query' => array( + array( + 'key' => '_subscription_switch', + 'value' => $subscription_id, + ), + ), + ) ); + + foreach ( $order_ids as $order_id ) { + $orders[ $order_id ] = wc_get_order( $order_id ); + } + + return $orders; +} + +/** + * Checks if a given product is of a switchable type + * + * @param int|WC_Product $product A WC_Product object or the ID of a product to check + * @return bool + * @since 2.0 + */ +function wcs_is_product_switchable_type( $product ) { + + if ( ! is_object( $product ) ) { + $product = wc_get_product( $product ); + } + + $variation = null; + + if ( empty( $product ) ) { + + $is_product_switchable = false; + + } else { + + // back compat for parent products + if ( $product->is_type( 'subscription_variation' ) && ! empty( $product->parent ) ) { + $variation = $product; + $product = $product->parent; + } + + $allow_switching = get_option( WC_Subscriptions_Admin::$option_prefix . '_allow_switching', 'no' ); + + switch ( $allow_switching ) { + case 'variable' : + $is_product_switchable = ( $product->is_type( array( 'variable-subscription', 'subscription_variation' ) ) ) ? true : false; + break; + case 'grouped' : + $is_product_switchable = ( 0 !== $product->post->post_parent ) ? true : false; + break; + case 'variable_grouped' : + $is_product_switchable = ( $product->is_type( array( 'variable-subscription', 'subscription_variation' ) ) || 0 !== $product->post->post_parent ) ? true : false; + break; + case 'no' : + default: + $is_product_switchable = false; + break; + } + } + + return apply_filters( 'wcs_is_product_switchable', $is_product_switchable, $product, $variation ); +} diff --git a/includes/wcs-time-functions.php b/includes/wcs-time-functions.php new file mode 100644 index 0000000..cf38bab --- /dev/null +++ b/includes/wcs-time-functions.php @@ -0,0 +1,629 @@ + sprintf( _nx( 'day', '%s days', $number, 'Subscription billing period.', 'woocommerce-subscriptions' ), $number ), + // translators: placeholder is number of weeks. (e.g. "Bill this every week / 4 weeks") + 'week' => sprintf( _nx( 'week', '%s weeks', $number, 'Subscription billing period.', 'woocommerce-subscriptions' ), $number ), + // translators: placeholder is number of months. (e.g. "Bill this every month / 4 months") + 'month' => sprintf( _nx( 'month', '%s months', $number, 'Subscription billing period.', 'woocommerce-subscriptions' ), $number ), + // translators: placeholder is number of years. (e.g. "Bill this every year / 4 years") + 'year' => sprintf( _nx( 'year', '%s years', $number, 'Subscription billing period.', 'woocommerce-subscriptions' ), $number ), + ) + ); + + return ( ! empty( $period ) ) ? $translated_periods[ $period ] : $translated_periods; +} + +/** + * Return an i18n'ified associative array of all possible subscription trial periods. + * + * @param int (optional) An interval in the range 1-6 + * @param string (optional) One of day, week, month or year. If empty, all subscription ranges are returned. + * @since 2.0 + */ +function wcs_get_subscription_trial_period_strings( $number = 1, $period = '' ) { + + $translated_periods = apply_filters( 'woocommerce_subscription_trial_periods', + array( + 'day' => sprintf( _n( '%s day', 'a %s-day', $number, 'woocommerce-subscriptions' ), $number ), + 'week' => sprintf( _n( '%s week', 'a %s-week', $number, 'woocommerce-subscriptions' ), $number ), + 'month' => sprintf( _n( '%s month', 'a %s-month', $number, 'woocommerce-subscriptions' ), $number ), + 'year' => sprintf( _n( '%s year', 'a %s-year', $number, 'woocommerce-subscriptions' ), $number ), + ) + ); + + return ( ! empty( $period ) ) ? $translated_periods[ $period ] : $translated_periods; +} + +/** + * Returns an array of subscription lengths. + * + * PayPal Standard Allowable Ranges + * D – for days; allowable range is 1 to 90 + * W – for weeks; allowable range is 1 to 52 + * M – for months; allowable range is 1 to 24 + * Y – for years; allowable range is 1 to 5 + * + * @param string (optional) One of day, week, month or year. If empty, all subscription ranges are returned. + * @since 2.0 + */ +function wcs_get_subscription_ranges_tlc() { + + foreach ( array( 'day', 'week', 'month', 'year' ) as $period ) { + + $subscription_lengths = array( + _x( 'all time', 'Subscription length (eg "$10 per month for _all time_")', 'woocommerce-subscriptions' ), + ); + + switch ( $period ) { + case 'day': + $subscription_lengths[] = _x( '1 day', 'Subscription lengths. e.g. "For 1 day..."', 'woocommerce-subscriptions' ); + $subscription_range = range( 2, 90 ); + break; + case 'week': + $subscription_lengths[] = _x( '1 week', 'Subscription lengths. e.g. "For 1 week..."', 'woocommerce-subscriptions' ); + $subscription_range = range( 2, 52 ); + break; + case 'month': + $subscription_lengths[] = _x( '1 month', 'Subscription lengths. e.g. "For 1 month..."', 'woocommerce-subscriptions' ); + $subscription_range = range( 2, 24 ); + break; + case 'year': + $subscription_lengths[] = _x( '1 year', 'Subscription lengths. e.g. "For 1 year..."', 'woocommerce-subscriptions' ); + $subscription_range = range( 2, 5 ); + break; + } + + foreach ( $subscription_range as $number ) { + $subscription_range[ $number ] = wcs_get_subscription_period_strings( $number, $period ); + } + + // Add the possible range to all time range + $subscription_lengths += $subscription_range; + + $subscription_ranges[ $period ] = $subscription_lengths; + } + + return $subscription_ranges; +} + +/** + * Retaining the API, it makes use of the transient functionality. + * + * @param string $period + * @return bool|mixed + */ +function wcs_get_subscription_ranges( $subscription_period = '' ) { + if ( ! is_string( $subscription_period ) ) { + $subscription_period = ''; + } + + $locale = get_locale(); + + $subscription_ranges = WC_Subscriptions::$cache->cache_and_get( 'wcs-sub-ranges-' . $locale, 'wcs_get_subscription_ranges_tlc', array(), 3 * HOUR_IN_SECONDS ); + + $subscription_ranges = apply_filters( 'woocommerce_subscription_lengths', $subscription_ranges, $subscription_period ); + + if ( ! empty( $subscription_period ) ) { + return $subscription_ranges[ $subscription_period ]; + } else { + return $subscription_ranges; + } +} + +/** + * Return an i18n'ified associative array of all possible subscription periods. + * + * @param int (optional) An interval in the range 1-6 + * @since 2.0 + */ +function wcs_get_subscription_period_interval_strings( $interval = '' ) { + + $intervals = array( 1 => _x( 'every', 'period interval (eg "$10 _every_ 2 weeks")', 'woocommerce-subscriptions' ) ); + + foreach ( range( 2, 6 ) as $i ) { + // translators: period interval, placeholder is ordinal (eg "$10 every _2nd/3rd/4th_", etc) + $intervals[ $i ] = sprintf( _x( 'every %s', 'period interval with ordinal number (e.g. "every 2nd"', 'woocommerce-subscriptions' ), WC_Subscriptions::append_numeral_suffix( $i ) ); + } + + $intervals = apply_filters( 'woocommerce_subscription_period_interval_strings', $intervals ); + + if ( empty( $interval ) ) { + return $intervals; + } else { + return $intervals[ $interval ]; + } +} + +/** + * Return an i18n'ified associative array of all time periods allowed for subscriptions. + * + * @param string (Optional) Either 'singular' for singular trial periods or 'plural'. + * @since 2.0 + */ +function wcs_get_available_time_periods( $form = 'singular' ) { + + $number = ( 'singular' === $form ) ? 1 : 2; + + $translated_periods = apply_filters( 'woocommerce_subscription_available_time_periods', + array( + 'day' => _nx( 'day', 'days', $number, 'Used in the trial period dropdown. Number is in text field. 0, 2+ will need plural, 1 will need singular.', 'woocommerce-subscriptions' ), + 'week' => _nx( 'week', 'weeks', $number, 'Used in the trial period dropdown. Number is in text field. 0, 2+ will need plural, 1 will need singular.', 'woocommerce-subscriptions' ), + 'month' => _nx( 'month', 'months', $number, 'Used in the trial period dropdown. Number is in text field. 0, 2+ will need plural, 1 will need singular.', 'woocommerce-subscriptions' ), + 'year' => _nx( 'year', 'years', $number, 'Used in the trial period dropdown. Number is in text field. 0, 2+ will need plural, 1 will need singular.', 'woocommerce-subscriptions' ), + ) + ); + + return $translated_periods; +} + +/** + * Returns an array of allowed trial period lengths. + * + * @param string (optional) One of day, week, month or year. If empty, all subscription trial period lengths are returned. + * @since 2.0 + */ +function wcs_get_subscription_trial_lengths( $subscription_period = '' ) { + + $all_trial_periods = wcs_get_subscription_ranges(); + + foreach ( $all_trial_periods as $period => $trial_periods ) { + $all_trial_periods[ $period ][0] = _x( 'no', 'no trial period', 'woocommerce-subscriptions' ); + } + + if ( ! empty( $subscription_period ) ) { + return $all_trial_periods[ $subscription_period ]; + } else { + return $all_trial_periods; + } +} + +/** + * Convenience wrapper for adding "{n} {periods}" to a timestamp (e.g. 2 months or 5 days). + * + * @param int The number of periods to add to the timestamp + * @param string One of day, week, month or year. + * @param int A Unix timestamp to add the time too. + * @since 2.0 + */ +function wcs_add_time( $number_of_periods, $period, $from_timestamp ) { + + if ( 'month' == $period ) { + $next_timestamp = wcs_add_months( $from_timestamp, $number_of_periods ); + } else { + $next_timestamp = strtotime( "+ {$number_of_periods} {$period}", $from_timestamp ); + } + + return $next_timestamp; +} + +/** + * Workaround the last day of month quirk in PHP's strtotime function. + * + * Adding +1 month to the last day of the month can yield unexpected results with strtotime(). + * For example: + * - 30 Jan 2013 + 1 month = 3rd March 2013 + * - 28 Feb 2013 + 1 month = 28th March 2013 + * + * What humans usually want is for the date to continue on the last day of the month. + * + * @param int $from_timestamp A Unix timestamp to add the months too. + * @param int $months_to_add The number of months to add to the timestamp. + * @since 2.0 + */ +function wcs_add_months( $from_timestamp, $months_to_add ) { + + $first_day_of_month = date( 'Y-m', $from_timestamp ) . '-1'; + $days_in_next_month = date( 't', strtotime( "+ {$months_to_add} month", strtotime( $first_day_of_month ) ) ); + + // Payment is on the last day of the month OR number of days in next billing month is less than the the day of this month (i.e. current billing date is 30th January, next billing date can't be 30th February) + if ( date( 'd m Y', $from_timestamp ) === date( 't m Y', $from_timestamp ) || date( 'd', $from_timestamp ) > $days_in_next_month ) { + for ( $i = 1; $i <= $months_to_add; $i++ ) { + $next_month = strtotime( '+ 3 days', $from_timestamp ); // Add 3 days to make sure we get to the next month, even when it's the 29th day of a month with 31 days + $next_timestamp = $from_timestamp = strtotime( date( 'Y-m-t H:i:s', $next_month ) ); // NB the "t" to get last day of next month + } + } else { // Safe to just add a month + $next_timestamp = strtotime( "+ {$months_to_add} month", $from_timestamp ); + } + + return $next_timestamp; +} + +/** + * Estimate how many days, weeks, months or years there are between now and a given + * date in the future. Estimates the minimum total of periods. + * + * @param int $start_timestamp A Unix timestamp + * @param int $end_timestamp A Unix timestamp at some time in the future + * @param string $end_timestamp A unit of time, either day, week month or year. + * @param string $unit_of_time A rounding method, either ceil (default) or floor for anything else + * @since 2.0 + */ +function wcs_estimate_periods_between( $start_timestamp, $end_timestamp, $unit_of_time = 'month', $rounding_method = 'ceil' ) { + + if ( $end_timestamp <= $start_timestamp ) { + + $periods_until = 0; + + } elseif ( 'month' == $unit_of_time ) { + + // Calculate the number of times this day will occur until we'll be in a time after the given timestamp + $timestamp = $start_timestamp; + + if ( 'ceil' == $rounding_method ) { + for ( $periods_until = 0; $timestamp < $end_timestamp; $periods_until++ ) { + $timestamp = wcs_add_months( $timestamp, 1 ); + } + } else { + for ( $periods_until = -1; $timestamp <= $end_timestamp; $periods_until++ ) { + $timestamp = wcs_add_months( $timestamp, 1 ); + } + } + } else { + + $seconds_until_timestamp = $end_timestamp - $start_timestamp; + + switch ( $unit_of_time ) { + + case 'day' : + $denominator = DAY_IN_SECONDS; + break; + + case 'week' : + $denominator = WEEK_IN_SECONDS; + break; + + case 'year' : + $denominator = YEAR_IN_SECONDS; + // we need to adjust this because YEAR_IN_SECONDS assumes a 365 day year. See notes on wcs_number_of_leap_days + $seconds_until_timestamp = $seconds_until_timestamp - wcs_number_of_leap_days( $start_timestamp, $end_timestamp ) * DAY_IN_SECONDS; + break; + } + + $periods_until = ( 'ceil' == $rounding_method ) ? ceil( $seconds_until_timestamp / $denominator ) : floor( $seconds_until_timestamp / $denominator ); + } + + return $periods_until; +} + +/** + * Utility function to find out how many leap days are there between two given dates. The reason we need this is because + * the constant YEAR_IN_SECONDS assumes a 365 year, which means some of the calculations are going to be off by a day. + * This has caused problems where if there's a leap year, wcs_estimate_periods_between would return 2 years instead of + * 1, making certain payments wildly inaccurate. + * + * @param int $start_timestamp A unix timestamp + * @param int $end_timestamp A unix timestamp + * + * @return int number of leap days between the start and end timstamps + */ +function wcs_number_of_leap_days( $start_timestamp, $end_timestamp ) { + if ( ! is_int( $start_timestamp ) || ! is_int( $end_timestamp ) ) { + throw new InvalidArgumentException( 'Start or end times are not integers' ); + } + // save the date! ;) + $default_tz = date_default_timezone_get(); + date_default_timezone_set( 'UTC' ); + + // Years to check + $years = range( date( 'Y', $start_timestamp ), date( 'Y', $end_timestamp ) ); + $leap_years = array_filter( $years, 'wcs_is_leap_year' ); + $total_feb_29s = 0; + + if ( ! empty( $leap_years ) ) { + // Let's get the first feb 29 in the list + $first_feb_29 = mktime( 23, 59, 59, 2, 29, reset( $leap_years ) ); + $last_feb_29 = mktime( 0, 0, 0, 2, 29, end( $leap_years ) ); + + $is_first_feb_covered = ( $first_feb_29 >= $start_timestamp ) ? 1: 0; + $is_last_feb_covered = ( $last_feb_29 <= $end_timestamp ) ? 1: 0; + + if ( count( $leap_years ) > 1 ) { + // the feb 29s are in different years + $total_feb_29s = count( $leap_years ) - 2 + $is_first_feb_covered + $is_last_feb_covered; + } else { + $total_feb_29s = ( $first_feb_29 >= $start_timestamp && $last_feb_29 <= $end_timestamp ) ? 1: 0; + } + } + date_default_timezone_set( $default_tz ); + + return $total_feb_29s; +} + +/** + * Filter function used in wcs_number_of_leap_days + * + * @param $year int A four digit year, eg 2017 + * + * @return bool|string + */ +function wcs_is_leap_year( $year ) { + return date( 'L', mktime( 0, 0, 0, 1, 1, $year ) ); +} +/** + * Method to try to determine the period of subscriptions if data is missing. It tries the following, in order: + * + * - defaults to month + * - comes up with an array of possible values given the standard time spans (day / week / month / year) + * - ranks them + * - discards 0 interval values + * - discards high deviation values + * - tries to match with passed in interval + * - if all else fails, sorts by interval and returns the one having the lowest interval, or the first, if equal (that should + * not happen though) + * + * @param string $last_date mysql date string + * @param string $second_date mysql date string + * @param integer $interval potential interval + * @return string period string + */ +function wcs_estimate_period_between( $last_date, $second_date, $interval = 1 ) { + + if ( ! is_int( $interval ) ) { + $interval = 1; + } + + $last_timestamp = strtotime( $last_date ); + $second_timestamp = strtotime( $second_date ); + + $earlier_timestamp = min( $last_timestamp, $second_timestamp ); + $later_timestamp = max( $last_timestamp, $second_timestamp ); + + $days_in_month = date( 't', $earlier_timestamp ); + $difference = absint( $last_timestamp - $second_timestamp ); + $period_in_seconds = round( $difference / $interval ); + $possible_periods = array(); + + // check for months + $full_months = wcs_find_full_months_between( $earlier_timestamp, $later_timestamp ); + + $possible_periods['month'] = array( + 'intervals' => $full_months['months'], + 'remainder' => $remainder = $full_months['remainder'], + 'fraction' => $remainder / ( 30 * DAY_IN_SECONDS ), + 'period' => 'month', + 'days_in_month' => $days_in_month, + 'original_interval' => $interval, + ); + + // check for different time spans + foreach ( array( 'year' => YEAR_IN_SECONDS, 'week' => WEEK_IN_SECONDS, 'day' => DAY_IN_SECONDS ) as $time => $seconds ) { + $possible_periods[ $time ] = array( + 'intervals' => floor( $period_in_seconds / $seconds ), + 'remainder' => $remainder = $period_in_seconds % $seconds, + 'fraction' => $remainder / $seconds, + 'period' => $time, + 'days_in_month' => $days_in_month, + 'original_interval' => $interval, + ); + } + + // filter out ones that are less than one period + $possible_periods_zero_filtered = array_filter( $possible_periods, 'wcs_discard_zero_intervals' ); + if ( empty( $possible_periods_zero_filtered ) ) { + // fall back if the difference is less than a day and return default 'day' + return 'day'; + } else { + $possible_periods = $possible_periods_zero_filtered; + } + + // filter out ones that have too high of a deviation + $possible_periods_no_hd = array_filter( $possible_periods, 'wcs_discard_high_deviations' ); + + if ( count( $possible_periods_no_hd ) == 1 ) { + // only one matched, let's return that as our best guess + $possible_periods_no_hd = array_shift( $possible_periods_no_hd ); + return $possible_periods_no_hd['period']; + } elseif ( count( $possible_periods_no_hd ) > 1 ) { + $possible_periods = $possible_periods_no_hd; + } + + // check for interval equality + $possible_periods_interval_match = array_filter( $possible_periods, 'wcs_match_intervals' ); + + if ( count( $possible_periods_interval_match ) == 1 ) { + foreach ( $possible_periods_interval_match as $period_data ) { + // only one matched the interval as our best guess + return $period_data['period']; + } + } elseif ( count( $possible_periods_interval_match ) > 1 ) { + $possible_periods = $possible_periods_interval_match; + } + + // order by number of intervals and return the lowest + + usort( $possible_periods, 'wcs_sort_by_intervals' ); + + $least_interval = array_shift( $possible_periods ); + + return $least_interval['period']; +} + +/** + * Finds full months between two dates and the remaining seconds after the end of the last full month. Takes into account + * leap years and variable number of days in months. Uses wcs_add_months + * + * @param numeric $start_timestamp unix timestamp of a start date + * @param numeric $end_timestamp unix timestamp of an end date + * @return array with keys 'months' (integer) and 'remainder' (seconds, integer) + */ +function wcs_find_full_months_between( $start_timestamp, $end_timestamp ) { + $number_of_months = 0; + $remainder = null; + $previous_remainder = null; + + while ( 0 <= $remainder ) { + $previous_timestamp = $start_timestamp; + $start_timestamp = wcs_add_months( $start_timestamp, 1 ); + $previous_remainder = $remainder; + $remainder = $end_timestamp - $start_timestamp; + + if ( $remainder >= 0 ) { + $number_of_months++; + } elseif ( null === $previous_remainder ) { + $previous_remainder = $end_timestamp - $previous_timestamp; + } + } + + $time_difference = array( + 'months' => $number_of_months, + 'remainder' => $previous_remainder, + ); + + return $time_difference; +} + +/** + * Used in an array_filter, removes elements where intervals are less than 0 + * + * @param array $array elements of an array + * @return bool true if at least 1 interval + */ +function wcs_discard_zero_intervals( $array ) { + return $array['intervals'] > 0; +} + +/** + * Used in an array_filter, discards high deviation elements. + * - for days it's 1/24th + * - for week it's 1/7th + * - for year it's 1/300th + * - for month it's 1/($days_in_months-2) + * + * @param array $array elements of the filtered array + * @return bool true if value is within deviation limit + */ +function wcs_discard_high_deviations( $array ) { + switch ( $array['period'] ) { + case 'year': + return $array['fraction'] < ( 1 / 300 ); + break; + case 'month': + return $array['fraction'] < ( 1 / ( $array['days_in_month'] - 2 ) ); + break; + case 'week': + return $array['fraction'] < ( 1 / 7 ); + break; + case 'day': + return $array['fraction'] < ( 1 / 24 ); + break; + default: + return false; + } +} + +/** + * Used in an array_filter, tries to match intervals against passed in interval + * @param array $array elements of filtered array + * @return bool true if intervals match + */ +function wcs_match_intervals( $array ) { + return $array['intervals'] == $array['original_interval']; +} + +/** + * Used in a usort, responsible for making sure the array is sorted in ascending order by intervals + * + * @param array $a one element of the sorted array + * @param array $b different element of the sorted array + * @return int 0 if equal, -1 if $b is larger, 1 if $a is larger + */ +function wcs_sort_by_intervals( $a, $b ) { + if ( $a['intervals'] == $b['intervals'] ) { + return 0; + } + return ( $a['intervals'] < $b['intervals'] ) ? -1 : 1; +} + +/** + * Used in a usort, responsible for making sure the array is sorted in descending order by fraction. + * + * @param array $a one element of the sorted array + * @param array $b different element of the sorted array + * @return int 0 if equal, -1 if $b is larger, 1 if $a is larger + */ +function wcs_sort_by_fractions( $a, $b ) { + if ( $a['fraction'] == $b['fraction'] ) { + return 0; + } + return ( $a['fraction'] > $b['fraction'] ) ? -1 : 1; +} + +/** + * PHP on Windows does not have strptime function. Therefore this is what we're using to check + * whether the given time is of a specific format. + * + * @param string $time the mysql time string + * @return boolean true if it matches our mysql pattern of YYYY-MM-DD HH:MM:SS + */ +function wcs_is_datetime_mysql_format( $time ) { + if ( ! is_string( $time ) ) { + return false; + } + + if ( function_exists( 'strptime' ) ) { + $valid_time = $match = ( false !== strptime( $time, '%Y-%m-%d %H:%M:%S' ) ) ? true : false; + } else { + // parses for the pattern of YYYY-MM-DD HH:MM:SS, but won't check whether it's a valid timedate + $match = preg_match( '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $time ); + + // parses time, returns false for invalid dates + $valid_time = strtotime( $time ); + } + + // magic number -2209078800 is strtotime( '1900-01-00 00:00:00' ). Needed to achieve parity with strptime + return ( $match && false !== $valid_time && -2209078800 <= $valid_time ) ? true : false; +} + +/** + * Find the average number of days for a given billing period and interval. + * + * @param string $period a billing period: day, week, month or year. + * @param int $interval a billing interval + * @return int the number of days in that billing cycle + */ +function wcs_get_days_in_cycle( $period, $interval ) { + + switch ( $period ) { + case 'day' : + $days_in_cycle = $interval; + break; + case 'week' : + $days_in_cycle = $interval * 7; + break; + case 'month' : + $days_in_cycle = $interval * 30.4375; // Average days per month over 4 year period + break; + case 'year' : + $days_in_cycle = $interval * 365.25; // Average days per year over 4 year period + break; + } + + return apply_filters( 'wcs_get_days_in_cycle', $days_in_cycle, $period, $interval ); +} diff --git a/includes/wcs-user-functions.php b/includes/wcs-user-functions.php new file mode 100644 index 0000000..d30de16 --- /dev/null +++ b/includes/wcs-user-functions.php @@ -0,0 +1,351 @@ +roles ) && in_array( 'administrator', $user->roles ) ) { + return; + } + + // Allow plugins to prevent Subscriptions from handling roles + if ( ! apply_filters( 'woocommerce_subscriptions_update_users_role', true, $user, $role_new ) ) { + return; + } + + $roles = wcs_get_new_user_role_names( $role_new ); + + $role_new = $roles['new']; + $role_old = $roles['old']; + + if ( ! empty( $role_old ) ) { + $user->remove_role( $role_old ); + } + + $user->add_role( $role_new ); + + do_action( 'woocommerce_subscriptions_updated_users_role', $role_new, $user, $role_old ); + return $user; +} + +/** + * Gets default new and old role names if the new role is 'default_subscriber_role'. Otherwise returns role_new and an + * empty string. + * + * @param $role_new string the new role of the user + * @return array with keys 'old' and 'new'. + */ +function wcs_get_new_user_role_names( $role_new ) { + $default_subscriber_role = get_option( WC_Subscriptions_Admin::$option_prefix . '_subscriber_role' ); + $default_cancelled_role = get_option( WC_Subscriptions_Admin::$option_prefix . '_cancelled_role' ); + $role_old = ''; + + if ( 'default_subscriber_role' == $role_new ) { + $role_old = $default_cancelled_role; + $role_new = $default_subscriber_role; + } elseif ( in_array( $role_new, array( 'default_inactive_role', 'default_cancelled_role' ) ) ) { + $role_old = $default_subscriber_role; + $role_new = $default_cancelled_role; + } + + return array( + 'new' => $role_new, + 'old' => $role_old, + ); +} + +/** + * Check if a user has a subscription, optionally to a specific product and/or with a certain status. + * + * @param int (optional) The ID of a user in the store. If left empty, the current user's ID will be used. + * @param int (optional) The ID of a product in the store. If left empty, the function will see if the user has any subscription. + * @param string (optional) A valid subscription status. If left empty, the function will see if the user has a subscription of any status. + * @since 2.0 + */ +function wcs_user_has_subscription( $user_id = 0, $product_id = '', $status = 'any' ) { + + $subscriptions = wcs_get_users_subscriptions( $user_id ); + + $has_subscription = false; + + if ( empty( $product_id ) ) { // Any subscription + + if ( ! empty( $status ) && 'any' != $status ) { // We need to check for a specific status + foreach ( $subscriptions as $subscription ) { + if ( $subscription->get_status() == $status ) { + $has_subscription = true; + break; + } + } + } elseif ( ! empty( $subscriptions ) ) { + $has_subscription = true; + } + } else { + + foreach ( $subscriptions as $subscription ) { + if ( $subscription->has_product( $product_id ) && ( empty( $status ) || 'any' == $status || $subscription->get_status() == $status ) ) { + $has_subscription = true; + break; + } + } + } + + return apply_filters( 'wcs_user_has_subscription', $has_subscription, $user_id, $product_id, $status ); +} + +/** + * Gets all the active and inactive subscriptions for a user, as specified by $user_id + * + * @param int $user_id (optional) The id of the user whose subscriptions you want. Defaults to the currently logged in user. + * @since 2.0 + */ +function wcs_get_users_subscriptions( $user_id = 0 ) { + + if ( 0 === $user_id || empty( $user_id ) ) { + $user_id = get_current_user_id(); + } + + $subscriptions = apply_filters( 'wcs_pre_get_users_subscriptions', array(), $user_id ); + + if ( empty( $subscriptions ) ) { + + $post_ids = get_posts( array( + 'posts_per_page' => -1, + 'post_status' => 'any', + 'post_type' => 'shop_subscription', + 'orderby' => 'date', + 'order' => 'desc', + 'meta_key' => '_customer_user', + 'meta_value' => $user_id, + 'meta_compare' => '=', + 'fields' => 'ids', + ) ); + + foreach ( $post_ids as $post_id ) { + $subscriptions[ $post_id ] = wcs_get_subscription( $post_id ); + } + } + + return apply_filters( 'wcs_get_users_subscriptions', $subscriptions, $user_id ); +} + +/** + * Return a link for subscribers to change the status of their subscription, as specified with $status parameter + * + * @param int $subscription_id A subscription's post ID + * @param string $status A subscription's post ID + * @param string $current_status A subscription's current status + * @since 1.0 + */ +function wcs_get_users_change_status_link( $subscription_id, $status, $current_status = '' ) { + + if ( '' === $current_status ) { + $subscription = wcs_get_subscription( $subscription_id ); + + if ( $subscription instanceof WC_Subscription ) { + $current_status = $subscription->get_status(); + } + } + + $action_link = add_query_arg( array( 'subscription_id' => $subscription_id, 'change_subscription_to' => $status ) ); + $action_link = wp_nonce_url( $action_link, $subscription_id . $current_status ); + + return apply_filters( 'wcs_users_change_status_link', $action_link, $subscription_id, $status ); +} + +/** + * Check if a given user (or the currently logged in user) has permission to put a subscription on hold. + * + * By default, a store manager can put all subscriptions on hold, while other users can only suspend their own subscriptions. + * + * @param int|WC_Subscription $subscription An instance of a WC_Snbscription object or ID representing a 'shop_subscription' post + * @since 2.0 + */ +function wcs_can_user_put_subscription_on_hold( $subscription, $user = '' ) { + + $user_can_suspend = false; + + if ( empty( $user ) ) { + $user = wp_get_current_user(); + } elseif ( is_int( $user ) ) { + $user = get_user_by( 'id', $user ); + } + + if ( user_can( $user, 'manage_woocommerce' ) ) { // Admin, so can always suspend a subscription + + $user_can_suspend = true; + + } else { // Need to make sure user owns subscription & the suspension limit hasn't been reached + + if ( ! is_object( $subscription ) ) { + $subscription = wcs_get_subscription( $subscription ); + } + + // Make sure current user owns subscription + if ( $user->ID == $subscription->get_user_id() ) { + + // Make sure subscription suspension count hasn't been reached + $suspension_count = $subscription->suspension_count; + $allowed_suspensions = get_option( WC_Subscriptions_Admin::$option_prefix . '_max_customer_suspensions', 0 ); + + if ( 'unlimited' === $allowed_suspensions || $allowed_suspensions > $suspension_count ) { // 0 not > anything so prevents a customer ever being able to suspend + $user_can_suspend = true; + } + } + } + + return apply_filters( 'wcs_can_user_put_subscription_on_hold', $user_can_suspend, $subscription ); +} + +/** + * Retrieve available actions that a user can perform on the subscription + * + * @since 2.0 + */ +function wcs_get_all_user_actions_for_subscription( $subscription, $user_id ) { + + $actions = array(); + + if ( user_can( $user_id, 'edit_shop_subscription_status', $subscription->id ) ) { + + $admin_with_suspension_disallowed = ( current_user_can( 'manage_woocommerce' ) && '0' === get_option( WC_Subscriptions_Admin::$option_prefix . '_max_customer_suspensions', '0' ) ) ? true : false; + $current_status = $subscription->get_status(); + + if ( $subscription->can_be_updated_to( 'on-hold' ) && wcs_can_user_put_subscription_on_hold( $subscription, $user_id ) && ! $admin_with_suspension_disallowed ) { + $actions['suspend'] = array( + 'url' => wcs_get_users_change_status_link( $subscription->id, 'on-hold', $current_status ), + 'name' => __( 'Suspend', 'woocommerce-subscriptions' ), + ); + } elseif ( $subscription->can_be_updated_to( 'active' ) && ! $subscription->needs_payment() ) { + $actions['reactivate'] = array( + 'url' => wcs_get_users_change_status_link( $subscription->id, 'active', $current_status ), + 'name' => __( 'Reactivate', 'woocommerce-subscriptions' ), + ); + } + + if ( wcs_can_user_resubscribe_to( $subscription, $user_id ) ) { + $actions['resubscribe'] = array( + 'url' => wcs_get_users_resubscribe_link( $subscription ), + 'name' => __( 'Resubscribe', 'woocommerce-subscriptions' ), + ); + } + + // Show button for subscriptions which can be cancelled and which may actually require cancellation (i.e. has a future payment) + if ( $subscription->can_be_updated_to( 'cancelled' ) && $subscription->get_time( 'next_payment' ) > 0 ) { + $actions['cancel'] = array( + 'url' => wcs_get_users_change_status_link( $subscription->id, 'cancelled', $current_status ), + 'name' => _x( 'Cancel', 'an action on a subscription', 'woocommerce-subscriptions' ), + ); + } + } + + return apply_filters( 'wcs_view_subscription_actions', $actions, $subscription ); +} + +/** + * Checks if a user has a certain capability + * + * @access public + * @param array $allcaps + * @param array $caps + * @param array $args + * @return bool + */ +function wcs_user_has_capability( $allcaps, $caps, $args ) { + if ( isset( $caps[0] ) ) { + switch ( $caps[0] ) { + case 'edit_shop_subscription_payment_method' : + $user_id = $args[1]; + $subscription = wcs_get_subscription( $args[2] ); + + if ( $user_id === $subscription->get_user_id() ) { + $allcaps['edit_shop_subscription_payment_method'] = true; + } + break; + case 'edit_shop_subscription_status' : + $user_id = $args[1]; + $subscription = wcs_get_subscription( $args[2] ); + + if ( $user_id === $subscription->get_user_id() ) { + $allcaps['edit_shop_subscription_status'] = true; + } + break; + case 'edit_shop_subscription_line_items' : + $user_id = $args[1]; + $subscription = wcs_get_subscription( $args[2] ); + + if ( $user_id === $subscription->get_user_id() ) { + $allcaps['edit_shop_subscription_line_items'] = true; + } + break; + case 'switch_shop_subscription' : + $user_id = $args[1]; + $subscription = wcs_get_subscription( $args[2] ); + + if ( $user_id === $subscription->get_user_id() ) { + $allcaps['switch_shop_subscription'] = true; + } + break; + case 'subscribe_again' : + $user_id = $args[1]; + $subscription = wcs_get_subscription( $args[2] ); + + if ( $user_id === $subscription->get_user_id() ) { + $allcaps['subscribe_again'] = true; + } + break; + } + } + return $allcaps; +} +add_filter( 'user_has_cap', 'wcs_user_has_capability', 10, 3 ); diff --git a/languages/woocommerce-subscriptions.pot b/languages/woocommerce-subscriptions.pot new file mode 100644 index 0000000..4aeb791 --- /dev/null +++ b/languages/woocommerce-subscriptions.pot @@ -0,0 +1,4197 @@ +# Copyright (C) 2016 Prospress Inc. +# This file is distributed under the same license as the WooCommerce Subscriptions package. +msgid "" +msgstr "" +"Project-Id-Version: WooCommerce Subscriptions 2.0.17\n" +"Report-Msgid-Bugs-To: " +"https://github.com/Prospress/woocommerce-subscriptions/issues\n" +"POT-Creation-Date: 2016-06-24 20:31:33+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: 2016-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Prospress Translations \n" +"X-Generator: grunt-wp-i18n 0.5.4\n" +"Language: en_US\n" + +#: includes/admin/class-wc-subscriptions-admin.php:123 +msgid "Simple Subscription" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:124 +#: woocommerce-subscriptions.php:601 +msgid "Variable Subscription" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:149 +#: templates/admin/deprecated/html-variation-price.php:20 +#: templates/admin/deprecated/html-variation-price.php:30 +#: templates/admin/html-variation-price.php:45 +#. translators: placeholder is a currency symbol / code +msgid "Subscription Price (%s)" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:162 +#: templates/admin/deprecated/html-variation-price.php:46 +msgid "Subscription Periods" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:171 +#: includes/admin/meta-boxes/views/html-subscription-schedule.php:32 +#: templates/admin/deprecated/html-variation-price.php:57 +msgid "Billing Period" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:182 +#: includes/admin/class-wc-subscriptions-admin.php:323 +#: templates/admin/deprecated/html-variation-price.php:69 +msgid "Subscription Length" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:192 +#: templates/admin/deprecated/html-variation-price.php:85 +#. translators: %s is a currency symbol / code +msgid "Sign-up Fee (%s)" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:194 +msgid "" +"Optionally include an amount to be charged at the outset of the " +"subscription. The sign-up fee will be charged immediately, even if the " +"product has a free trial or the payment dates are synced." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:207 +#: templates/admin/deprecated/html-variation-price.php:97 +#: templates/admin/deprecated/html-variation-price.php:104 +msgid "Free Trial" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:214 +#: templates/admin/deprecated/html-variation-price.php:115 +msgid "Subscription Trial Period" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:244 +msgid "One Time Shipping" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:245 +msgid "" +"Shipping for subscription products is normally charged on the initial order " +"and all renewal orders. Enable this to only charge shipping once on the " +"initial order. Note: for shipping to be charged on the initial order, the " +"subscription must not have a free trial." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:267 +msgid "Limit Subscription" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:269 +#. translators: placeholders are opening and closing link tags +msgid "" +"Only allow a customer to have one subscription to this product. %sLearn " +"more%s." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:271 +msgid "Do not limit" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:272 +msgid "Limit to one active subscription" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:273 +msgid "Limit to one of any status" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:319 +msgid "Subscription Pricing" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:320 +msgid "Subscription Sign-up Fee" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:321 +msgid "Subscription Billing Interval" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:322 +msgid "Subscription Period" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:324 +msgid "Free Trial Length" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:325 +msgid "Free Trial Period" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:672 +msgid "Enter the new period, either day, week, month or year:" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:673 +msgid "Enter a new length (e.g. 5):" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:674 +msgid "" +"Enter a new interval as a single number (e.g. to charge every 2nd month, " +"enter 2):" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:678 +msgid "" +"You are about to trash one or more orders which contain a subscription.\n" +"\n" +"Trashing the orders will also trash the subscriptions purchased with these " +"orders." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:689 +msgid "" +"Trashing this order will also trash the subscription purchased with the " +"order." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:690 +msgid "" +"WARNING: Bad things are about to happen!\n" +"\n" +"The payment gateway used to purchase this subscription does not support " +"modifying a subscription's details.\n" +"\n" +"Changes to the billing period, recurring discount, recurring tax or " +"recurring total may not be reflected in the amount charged by the payment " +"gateway." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:691 +msgid "" +"You are deleting a subscription item. You will also need to manually cancel " +"and trash the subscription on the Manage Subscriptions screen." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:698 +msgid "" +"Warning: Deleting a user will also delete the user's subscriptions. The " +"user's orders will remain but be reassigned to the 'Guest' user.\n" +"\n" +"Do you want to continue to delete this user and any associated " +"subscriptions?" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:761 +msgid "Active Subscriber?" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:804 +msgid "Manage Subscriptions" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:808 +#: woocommerce-subscriptions.php:209 +msgid "Search Subscriptions" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:828 +#: includes/admin/class-wc-subscriptions-admin.php:924 +#: includes/class-wcs-query.php:90 includes/class-wcs-query.php:110 +#: includes/class-wcs-query.php:112 woocommerce-subscriptions.php:200 +#: woocommerce-subscriptions.php:213 +msgid "Subscriptions" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:971 +#. translators: $1-2: opening and closing tags of a link that takes to PayPal +#. settings, $3-4: opening and closing tags of a link that takes to Woo +#. marketplace / Stripe product page +msgid "" +"No payment gateways capable of processing automatic subscription payments " +"are enabled. Please enable the %1$sPayPal Standard%2$s gateway or get the " +"%3$sfree Stripe extension%4$s if you want to process automatic payments." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:974 +#. translators: placeholder is name of a gateway +msgid "The %s gateway can process automatic subscription payments." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:977 +#. translators: %1$s - a comma separated list of gateway names (e.g. "stripe, +#. paypal, worldpay"), %2$s - one name of gateway (e.g. "authorize.net") +msgid "The %1$s & %2$s gateways can process automatic subscription payments." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:983 +msgid "Button Text" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:990 +msgid "Add to Cart Button Text" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:991 +msgid "" +"A product displays a button with the text \"Add to Cart\". By default, a " +"subscription changes this to \"Sign Up Now\". You can customise the button " +"text for subscriptions here." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:995 +#: includes/admin/class-wc-subscriptions-admin.php:1006 +#: includes/class-wc-product-subscription-variation.php:75 +#: includes/class-wc-product-subscription.php:115 +#: includes/class-wc-product-variable-subscription.php:101 +#: includes/class-wc-subscriptions-product.php:124 +#: woocommerce-subscriptions.php:456 woocommerce-subscriptions.php:1110 +msgid "Sign Up Now" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1001 +msgid "Place Order Button Text" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1002 +msgid "" +"Use this field to customise the text displayed on the checkout button when " +"an order contains a subscription. Normally the checkout submission button " +"displays \"Place Order\". When the cart contains a subscription, this is " +"changed to \"Sign Up Now\"." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1014 +msgid "Roles" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1017 +#. translators: placeholders are tags +msgid "" +"Choose the default roles to assign to active and inactive subscribers. For " +"record keeping purposes, a user account must be created for subscribers. " +"Users with the %sadministrator%s role, such as yourself, will never be " +"allocated these roles to prevent locking out administrators." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1022 +msgid "Subscriber Default Role" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1023 +msgid "" +"When a subscription is activated, either manually or after a successful " +"purchase, new users will be assigned this role." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1034 +msgid "Inactive Subscriber Role" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1035 +msgid "" +"If a subscriber's subscription is manually cancelled or expires, she will " +"be assigned this role." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1055 +msgid "Manual Renewal Payments" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1056 +msgid "Accept Manual Renewals" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1061 +#. translators: placeholders are opening and closing link tags +msgid "" +"With manual renewals, a customer's subscription is put on-hold until they " +"login and pay to renew it. %sLearn more%s." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1067 +msgid "Turn off Automatic Payments" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1072 +#. translators: placeholders are opening and closing link tags +msgid "" +"If you never want a customer to be automatically charged for a subscription " +"renewal payment, you can turn off automatic payments completely. %sLearn " +"more%s." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1087 +msgid "Customer Suspensions" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1094 +msgid "" +"Set a maximum number of times a customer can suspend their account for each " +"billing period. For example, for a value of 3 and a subscription billed " +"yearly, if the customer has suspended their account 3 times, they will not " +"be presented with the option to suspend their account until the next year. " +"Store managers will always be able able to suspend an active subscription. " +"Set this to 0 to turn off the customer suspension feature completely." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1098 +msgid "Mixed Checkout" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1099 +msgid "Allow subscriptions and products to be purchased simultaneously." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1103 +msgid "Allow subscriptions and products to be purchased in a single transaction." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1107 +#: includes/upgrades/templates/wcs-about.php:108 +msgid "Drip Downloadable Content" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1108 +msgid "Enable dripping for downloadable content on subscription products." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1112 +msgid "" +"Enabling this grants access to new downloadable files added to a product " +"only after the next renewal is processed.%sBy default, access to new " +"downloadable files added to a product is granted immediately to any " +"customer that has an active subscription with that product." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1118 +msgid "Payment Gateways" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1126 +#. translators: placeholders are opening and closing link tags +msgid "" +"Other payment gateways can be used to process %smanual subscription renewal " +"payments%s only." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1133 +#. translators: $1-$2: opening and closing tags. Link to documents->payment +#. gateways, 3$-4$: opening and closing tags. Link to woothemes extensions shop +#. page +msgid "" +"Find new gateways that %1$ssupport automatic subscription payments%2$s in " +"the official %3$sWooCommerce Marketplace%4$s." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1170 +#. translators: $1-$2: opening and closing tags, $3-$4: opening and +#. closing tags +msgid "" +"%1$sWooCommerce Subscriptions Installed%2$s – %3$sYou're ready to " +"start selling subscriptions!%4$s" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1175 +msgid "Add a Subscription Product" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1176 +#: includes/upgrades/templates/wcs-about.php:35 +#: woocommerce-subscriptions.php:960 +msgid "Settings" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1262 +#. translators: placeholder is a number +msgid "We can't find a subscription with ID #%d. Perhaps it was deleted?" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1295 +#: includes/admin/class-wc-subscriptions-admin.php:1300 +#. translators: placeholders are opening link tag, ID of sub, and closing link +#. tag +msgid "Showing orders for %sSubscription %s%s" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1324 +#. translators: number of 1$: days, 2$: weeks, 3$: months, 4$: years +msgid "The trial period can not exceed: %1s, %2s, %3s or %4s." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1329 +#. translators: placeholder is a time period (e.g. "4 weeks") +msgid "The trial period can not exceed %s." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1383 +#: includes/admin/class-wc-subscriptions-admin.php:1436 +msgid "Yes" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1383 +msgid "No" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1419 +msgid "Automatic Recurring Payments" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1436 +msgid "" +"Supports automatic renewal payments with the WooCommerce Subscriptions " +"extension." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:60 +#: includes/admin/class-wcs-admin-meta-boxes.php:64 +#: templates/myaccount/related-orders.php:15 +msgid "Related Orders" +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:103 +msgid "Please enter a start date in the past." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:104 +msgid "Please enter a date at least one hour into the future." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:105 +msgid "Please enter a date after the trial end." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:106 +#: includes/admin/class-wcs-admin-meta-boxes.php:107 +msgid "Please enter a date after the start date." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:108 +msgid "Please enter a date before the next payment." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:109 +msgid "Please enter a date after the next payment." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:110 +msgid "" +"Are you sure you want to process a renewal?\n" +"\n" +"This will charge the customer and email them the renewal order (if emails " +"are enabled)." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:130 +msgid "Process renewal" +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:133 +msgid "Create pending renewal order" +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:147 +msgid "Process renewal order action requested by admin." +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:166 +msgid "Create pending renewal order requested by admin action." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:111 +msgid "Search for a product…" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:284 +#. translators: placeholder is the number of subscriptions updated +msgid "%s subscription status changed." +msgid_plural "%s subscription statuses changed." +msgstr[0] "" +msgstr[1] "" + +#: includes/admin/class-wcs-admin-post-types.php:291 +#. translators: 1$: is the number of subscriptions not updated, 2$: is the +#. error message +msgid "%1$s subscription could not be updated: %2$s" +msgid_plural "%1$s subscriptions could not be updated: %2$s" +msgstr[0] "" +msgstr[1] "" + +#: includes/admin/class-wcs-admin-post-types.php:313 +#: includes/admin/meta-boxes/views/html-related-orders-table.php:20 +#: templates/myaccount/my-subscriptions.php:26 +#: templates/myaccount/my-subscriptions.php:40 +#: templates/myaccount/related-orders.php:24 +#: templates/myaccount/related-orders.php:44 +#: templates/myaccount/related-subscriptions.php:21 +#: templates/myaccount/related-subscriptions.php:35 +#: templates/myaccount/view-subscription.php:32 +msgid "Status" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:314 +#: includes/admin/meta-boxes/class-wcs-meta-box-related-orders.php:61 +#: templates/emails/cancelled-subscription.php:26 +#: templates/emails/subscription-info.php:18 +#: templates/myaccount/my-subscriptions.php:25 +#: templates/myaccount/related-subscriptions.php:20 +#: woocommerce-subscriptions.php:201 +msgid "Subscription" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:315 +msgid "Items" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:316 +msgid "Total" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:317 +msgid "Start Date" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:318 +msgid "Trial End" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:319 +msgid "Next Payment" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:320 +msgid "Last Payment" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:321 +msgid "End Date" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:364 +#: includes/wcs-user-functions.php:272 +msgid "Reactivate" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:365 +#: includes/wcs-user-functions.php:267 +msgid "Suspend" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:367 +#: includes/admin/class-wcs-admin-post-types.php:382 +msgid "Trash" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:368 +#: includes/admin/class-wcs-admin-post-types.php:386 +msgid "Delete Permanently" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:380 +#: includes/class-wc-subscriptions-product.php:805 +msgid "Restore this item from the Trash" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:380 +#: includes/class-wc-subscriptions-product.php:806 +msgid "Restore" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:382 +msgid "Move this item to the Trash" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:386 +msgid "Delete this item permanently" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:392 +msgid "Cancel Now" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:425 +#: templates/emails/plain/admin-new-renewal-order.php:54 +#: templates/emails/plain/customer-completed-renewal-order.php:52 +#: templates/emails/plain/customer-processing-renewal-order.php:51 +#. translators: placeholder is customer's billing email +msgid "Email: %s" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:430 +#: templates/emails/plain/admin-new-renewal-order.php:59 +#: templates/emails/plain/customer-completed-renewal-order.php:58 +#: templates/emails/plain/customer-processing-renewal-order.php:57 +#. translators: placeholder is customer's billing phone number +msgid "Tel: %s" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:504 +msgid "%d item" +msgid_plural "%d items" +msgstr[0] "" +msgstr[1] "" + +#: includes/admin/class-wcs-admin-post-types.php:539 +#: templates/myaccount/my-subscriptions.php:48 +#. translators: placeholder is the display name of a payment gateway a +#. subscription was paid by +msgid "Via %s" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:550 +msgid "Y/m/d g:i:s A" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:553 +msgid "" +"This date should be treated as an estimate only. The payment gateway for " +"this subscription controls when payments are processed." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:826 +#: includes/admin/class-wcs-admin-post-types.php:829 +#: includes/admin/class-wcs-admin-post-types.php:832 +msgid "Subscription updated." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:827 +msgid "Custom field updated." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:828 +msgid "Custom field deleted." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:833 +msgid "Subscription saved." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:834 +msgid "Subscription submitted." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:836 +#. translators: php date string +msgid "Subscription scheduled for: %1$s." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:837 +msgid "Subscription draft updated." +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:875 +msgid "Any Payment Method" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:876 +msgid "None" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:54 +msgid "Customer:" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:63 +msgid "View other subscriptions" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:76 +msgid "Search for a customer…" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:80 +msgid "Subscription Status:" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:98 +msgid "Billing Details" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:104 +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:106 +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:172 +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:174 +msgid "Address" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:106 +msgid "No billing address set." +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:122 +#: includes/class-wcs-change-payment-method-admin.php:38 +#: includes/class-wcs-change-payment-method-admin.php:51 +msgid "Payment Method" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:161 +msgid "Shipping Details" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:174 +msgid "No shipping address set." +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:192 +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:219 +msgid "Customer Note:" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:220 +msgid "Customer's notes about the order" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:275 +#. translators: placeholder is error message from the payment gateway or +#. subscriptions when updating the status +msgid "Error updating subscription: %s" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-row.php:34 +#: includes/class-wc-subscription.php:679 +#: includes/class-wc-subscriptions-manager.php:2328 +#. translators: placeholder is human time diff (e.g. "3 weeks") +msgid "In %s" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-row.php:37 +#: includes/class-wc-subscription.php:682 +#. translators: placeholder is human time diff (e.g. "3 weeks") +msgid "%s ago" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-row.php:43 +msgid "Unpublished" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-table.php:17 +#: templates/myaccount/related-orders.php:36 +msgid "Order Number" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-table.php:18 +msgid "Relationship" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-table.php:19 +#: templates/myaccount/related-orders.php:23 +#: templates/myaccount/related-orders.php:41 +msgid "Date" +msgstr "" + +#: includes/admin/meta-boxes/views/html-subscription-schedule.php:22 +#: includes/admin/meta-boxes/views/html-subscription-schedule.php:41 +msgid "Recurring:" +msgstr "" + +#: includes/admin/meta-boxes/views/html-subscription-schedule.php:60 +msgid "Timezone:" +msgstr "" + +#: includes/admin/meta-boxes/views/html-subscription-schedule.php:60 +msgid "Error: unable to find timezone of your browser." +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:102 wcs-functions.php:168 +msgid "Invalid subscription status given." +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:124 +msgid "You do not have permission to read the subscriptions count" +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:173 +msgid "You do not have permission to create subscriptions" +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:239 +msgid "The requested subscription cannot be edited." +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:304 +msgid "" +"Gateway does not support admin changing the payment method on a " +"Subscription." +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:342 +#. translators: 1$: gateway id, 2$: error message +msgid "" +"Subscription payment method could not be set to %1$s and has been set to " +"manual with error message: %2$s" +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:377 wcs-functions.php:142 +msgid "" +"Invalid subscription billing interval given. Must be an integer greater " +"than 0." +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:389 wcs-functions.php:137 +msgid "Invalid subscription billing period given." +msgstr "" + +#: includes/class-wc-subscription.php:307 +#: includes/class-wc-subscription.php:398 +msgid "Unable to change subscription status to \"%s\"." +msgstr "" + +#: includes/class-wc-subscription.php:380 +#. translators: $1 note why the status changes (if any), $2: old status, $3: +#. new status +msgid "%1$s Status changed from %2$s to %3$s." +msgstr "" + +#: includes/class-wc-subscription.php:689 +msgid "Not yet ended" +msgstr "" + +#: includes/class-wc-subscription.php:732 +msgid "Invalid format. First parameter needs to be an array." +msgstr "" + +#: includes/class-wc-subscription.php:736 +msgid "Invalid data. First parameter was empty when passed to update_dates()." +msgstr "" + +#: includes/class-wc-subscription.php:743 +msgid "" +"Invalid data. First parameter has a date that is not in the registered date " +"types." +msgstr "" + +#: includes/class-wc-subscription.php:791 +msgid "The %s date must occur after the last payment date." +msgstr "" + +#: includes/class-wc-subscription.php:795 +msgid "The %s date must occur after the next payment date." +msgstr "" + +#: includes/class-wc-subscription.php:800 +msgid "The %s date must occur after the trial end date." +msgstr "" + +#: includes/class-wc-subscription.php:804 +msgid "The %s date must occur after the start date." +msgstr "" + +#: includes/class-wc-subscription.php:859 +msgid "The start date of a subscription can not be deleted, only updated." +msgstr "" + +#: includes/class-wc-subscription.php:862 +msgid "" +"The last payment date of a subscription can not be deleted. You must delete " +"the order." +msgstr "" + +#: includes/class-wc-subscription.php:1263 +msgid "Sign-up complete." +msgstr "" + +#: includes/class-wc-subscription.php:1265 +msgid "Payment received." +msgstr "" + +#: includes/class-wc-subscription.php:1296 +msgid "Payment failed." +msgstr "" + +#: includes/class-wc-subscription.php:1300 +msgid "Subscription Cancelled: maximum number of failed payments reached." +msgstr "" + +#: includes/class-wc-subscription.php:1495 +#: includes/class-wcs-change-payment-method-admin.php:155 +msgid "Manual Renewal" +msgstr "" + +#: includes/class-wc-subscription.php:1560 +msgid "Payment method meta must be an array." +msgstr "" + +#: includes/class-wc-subscriptions-addresses.php:46 +msgid "Change Address" +msgstr "" + +#: includes/class-wc-subscriptions-addresses.php:70 +msgid "" +"Both the shipping address used for the subscription and your default " +"shipping address for future purchases will be updated." +msgstr "" + +#: includes/class-wc-subscriptions-addresses.php:83 +#. translators: $1: address type (Shipping Address / Billing Address), $2: +#. opening tag, $3: closing tag +msgid "Update the %1$s used for %2$sall%3$s of my active subscriptions" +msgstr "" + +#: includes/class-wc-subscriptions-cart.php:864 +msgid "Please enter a valid postcode/ZIP." +msgstr "" + +#: includes/class-wc-subscriptions-cart.php:1035 +msgid "" +"That subscription product can not be added to your cart as it already " +"contains a subscription renewal." +msgstr "" + +#: includes/class-wc-subscriptions-cart.php:1116 +msgid "Invalid recurring shipping method." +msgstr "" + +#: includes/class-wc-subscriptions-cart.php:2002 +msgid "now" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:126 +#: templates/emails/plain/admin-new-switch-order.php:57 +#: templates/emails/plain/cancelled-subscription.php:20 +#: templates/emails/plain/customer-completed-switch-order.php:52 +#. translators: placeholder is the subscription order number wrapped in +#. tags +msgid "Subscription Number: %s" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:132 +#. translators: placeholder is the subscription's next payment date (either +#. human readable or normal date) wrapped in tags +msgid "Next Payment Date: %s" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:138 +#. translators: placeholder is the formatted total to be paid for the +#. subscription wrapped in tags +msgid "Total: %s" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:145 +#. translators: placeholder is the display name of the payment method +msgid "Payment Method: %s" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:157 +msgid "" +"Sorry, this subscription change payment method request is invalid and " +"cannot be processed." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:189 +msgid "There was an error with your request. Please try again." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:193 +#: templates/myaccount/view-subscription.php:20 +msgid "Invalid Subscription." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:197 +#: includes/class-wcs-cart-resubscribe.php:58 +#: includes/class-wcs-user-change-status-handler.php:103 +msgid "That doesn't appear to be one of your subscriptions." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:201 +msgid "The payment method can not be changed for that subscription." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:207 +#. translators: placeholder is next payment's date +msgid " Next payment is due %s." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:213 +#. translators: placeholder is either empty or "Next payment is due..." +msgid "Choose a new payment method.%s" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:235 +msgid "Invalid order." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:328 +msgid "Payment method updated." +msgstr "" + +#: includes/class-wc-subscriptions-checkout.php:167 +#: includes/class-wc-subscriptions-checkout.php:279 +#. translators: placeholder is an internal error number +msgid "Error %d: Unable to create subscription. Please try again." +msgstr "" + +#: includes/class-wc-subscriptions-checkout.php:180 +#. translators: placeholder is an internal error number +msgid "Error %d: Unable to add tax to subscription. Please try again." +msgstr "" + +#: includes/class-wc-subscriptions-checkout.php:188 +#. translators: placeholder is an internal error number +msgid "Error %d: Unable to create order. Please try again." +msgstr "" + +#: includes/class-wc-subscriptions-checkout.php:244 +#: includes/class-wc-subscriptions-manager.php:467 +msgid "Error: Unable to create subscription. Please try again." +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:58 +msgid "Sign Up Fee Discount" +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:59 +msgid "Sign Up Fee % Discount" +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:60 +msgid "Recurring Product Discount" +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:61 +msgid "Recurring Product % Discount" +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:249 +msgid "" +"Sorry, this coupon is only valid for an initial payment and the cart does " +"not require an initial payment." +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:255 +msgid "Sorry, this coupon is only valid for new subscriptions." +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:260 +msgid "Sorry, this coupon is only valid for subscription products." +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:266 +#. translators: 1$: coupon code that is being removed +msgid "Sorry, the \"%1$s\" coupon is only valid for renewals." +msgstr "" + +#: includes/class-wc-subscriptions-coupon.php:271 +msgid "" +"Sorry, this coupon is only valid for subscription products with a sign-up " +"fee." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:104 +msgid "" +"Error: Unable to create renewal order from scheduled payment. Please try " +"again." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:141 +#: includes/gateways/class-wc-subscriptions-payment-gateways.php:183 +msgid "Subscription doesn't exist in scheduled action: %d" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:278 +#. translators: $1: order number, $2: error message +msgid "Failed to activate subscription status for order #%1$s: %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:306 +#. translators: $1: order number, $2: error message +msgid "Failed to update subscription status after order #%1$s was put on-hold: %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:334 +#. translators: $1: order number, $2: error message +msgid "Failed to cancel subscription after order #%1$s was cancelled: %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:362 +#. translators: $1: order number, $2: error message +msgid "Failed to set subscription as expired for order #%1$s: %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:388 +msgid "Subscription sign up failed." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:398 +#. translators: $1: order number, $2: error message +msgid "Failed to process failed payment on subscription for order #%1$s: %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:486 +msgid "Error: Unable to add product to created subscription. Please try again." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:526 +msgid "Pending subscription created." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:1752 +#. translators: all fields are full html nodes: 1$: month input, 2$: day input, +#. 3$: year input, 4$: hour input, 5$: minute input. Change the order if you'd +#. like +msgid "%1$s%2$s, %3$s @ %4$s : %5$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:1756 +#. translators: all fields are full html nodes: 1$: month input, 2$: day input, +#. 3$: year input. Change the order if you'd like +msgid "%1$s%2$s, %3$s" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:1761 +msgid "Change" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:2210 +#. translators: placeholder is subscription ID +msgid "Failed sign-up for subscription %s." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:2301 +msgid "Invalid security token, please reload the page and try again." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:2305 +msgid "Only store managers can edit payment dates." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:2309 +msgid "Please enter all date fields." +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:2334 +msgid "Date Changed" +msgstr "" + +#: includes/class-wc-subscriptions-order.php:362 +msgid "Your subscription will be activated when payment clears." +msgid_plural "Your subscriptions will be activated when payment clears." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-wc-subscriptions-order.php:365 +#. translators: placeholders are opening and closing link tags +msgid "View the status of your subscription in %syour account%s." +msgid_plural "View the status of your subscriptions in %syour account%s." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-wc-subscriptions-order.php:635 +msgid "Show all types" +msgstr "" + +#: includes/class-wc-subscriptions-order.php:884 +#. translators: $1: opening link tag, $2: order number, $3: closing link tag +msgid "Subscription cancelled for refunded order %1$s#%2$s%3$s." +msgstr "" + +#: includes/class-wc-subscriptions-product.php:345 +#: includes/wcs-formatting-functions.php:102 +#: includes/wcs-formatting-functions.php:186 +#. translators: 1$: recurring amount string, 2$: day of the week (e.g. "$10 +#. every Wednesday") +msgid "%1$s every %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:348 +#: includes/wcs-formatting-functions.php:111 +#. translators: 1$: recurring amount string, 2$: period, 3$: day of the week +#. (e.g. "$10 every 2nd week on Wednesday") +msgid "%1$s every %2$s on %3$s" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:355 +#: includes/wcs-formatting-functions.php:129 +#. translators: placeholder is recurring amount +msgid "%s on the last day of each month" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:358 +#: includes/wcs-formatting-functions.php:132 +#. translators: 1$: recurring amount, 2$: day of the month (e.g. "23rd") (e.g. +#. "$5 every 23rd of each month") +msgid "%1$s on the %2$s of each month" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:363 +#: includes/wcs-formatting-functions.php:148 +#. translators: 1$: recurring amount, 2$: interval (e.g. "3rd") (e.g. "$10 on +#. the last day of every 3rd month") +msgid "%1$s on the last day of every %2$s month" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:366 +#: includes/wcs-formatting-functions.php:151 +#. translators: 1$: on the, 2$: day of every, 3$: +#. month (e.g. "$10 on the 23rd day of every 2nd month") +#. translators: 1$: recurring amount, 2$: day of the month (e.g. "23rd") (e.g. +#. "$5 every 23rd of each month") +msgid "%1$s on the %2$s day of every %3$s month" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:373 +#: includes/wcs-formatting-functions.php:164 +#. translators: 1$: on, 2$: , 3$: each year (e.g. "$15 on +#. March 15th each year") +#. translators: 1$: recurring amount, 2$: month (e.g. "March"), 3$: day of the +#. month (e.g. "23rd") (e.g. "$15 on March 15th every 3rd year") +msgid "%1$s on %2$s %3$s each year" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:376 +#: includes/wcs-formatting-functions.php:173 +#. translators: 1$: recurring amount, 2$: month (e.g. "March"), 3$: day of the +#. month (e.g. "23rd") (e.g. "$15 on March 15th every 3rd year") +msgid "%1$s on %2$s %3$s every %4$s year" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:382 +#: includes/wcs-formatting-functions.php:184 +#. translators: 1$: recurring amount, 2$: subscription period (e.g. "month" or +#. "3 months") (e.g. "$15 / month" or "$15 every 2nd month") +msgid "%1$s / %2$s" +msgid_plural " %1$s every %2$s" +msgstr[0] "" +msgstr[1] "" + +#: includes/class-wc-subscriptions-product.php:388 +#. translators: billing period (e.g. "every week") +msgid "every %s" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:394 +#: includes/wcs-formatting-functions.php:194 +#. translators: 1$: subscription string (e.g. "$10 up front then $5 on March +#. 23rd every 3rd year"), 2$: length (e.g. "4 years") +msgid "%1$s for %2$s" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:400 +#. translators: 1$: subscription string (e.g. "$15 on March 15th every 3 years +#. for 6 years"), 2$: trial length (e.g.: "with 4 months free trial") +msgid "%1$s with %2$s free trial" +msgstr "" + +#: includes/class-wc-subscriptions-product.php:405 +#. translators: 1$: subscription string (e.g. "$15 on March 15th every 3 years +#. for 6 years with 2 months free trial"), 2$: signup fee price (e.g. "and a +#. $30 sign-up fee") +msgid "%1$s and a %2$s sign-up fee" +msgstr "" + +#: includes/class-wc-subscriptions-renewal-order.php:139 +#. translators: placeholder is order ID +msgid "Order %s created to record renewal." +msgstr "" + +#: includes/class-wc-subscriptions-renewal-order.php:159 +msgid "Subscription renewal orders cannot be cancelled." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:143 +msgid "" +"You have a subscription to this product. Choosing a new subscription will " +"replace your existing subscription." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:145 +msgid "Choose a new subscription." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:166 +#: includes/class-wc-subscriptions-switcher.php:842 +msgid "" +"Your cart contained an invalid subscription switch request. It has been " +"removed." +msgid_plural "" +"Your cart contained invalid subscription switch requests. They have been " +"removed." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-wc-subscriptions-switcher.php:207 +msgid "" +"You have already subscribed to this product and it is limited to one per " +"customer. You can not purchase the product again." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:219 +#. translators: 1$: is the "You have already subscribed to this product" +#. notice, 2$-4$: opening/closing link tags, 3$: an order number +msgid "" +"%1$s Complete payment on %2$sOrder %3$s%4$s to be able to change your " +"subscription." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:303 +msgid "Switching" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:306 +#. translators: placeholders are opening and closing link tags +msgid "" +"Allow subscribers to switch (upgrade or downgrade) between different " +"subscriptions. %sLearn more%s." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:311 +msgid "Allow Switching" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:312 +msgid "" +"Allow subscribers to switch between subscriptions combined in a grouped " +"product, different variations of a Variable subscription or don't allow " +"switching." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:328 +msgid "Prorate Recurring Payment" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:329 +msgid "" +"When switching to a subscription with a different recurring payment or " +"billing period, should the price paid for the existing billing period be " +"prorated when switching to the new subscription?" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:346 +msgid "Prorate Sign up Fee" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:347 +msgid "" +"When switching to a subscription with a sign up fee, you can require the " +"customer pay only the gap between the existing subscription's sign up fee " +"and the new subscription's sign up fee (if any)." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:362 +msgid "Prorate Subscription Length" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:363 +msgid "" +"When switching to a subscription with a length, you can take into account " +"the payments already completed by the customer when determining how many " +"payments the subscriber needs to make for the new subscription." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:378 +msgid "Switch Button Text" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:379 +msgid "" +"Customise the text displayed on the button next to the subscription on the " +"subscriber's account page. The default is \"Switch Subscription\", but you " +"may wish to change this to \"Upgrade\" or \"Change Subscription\"." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:383 +#: includes/class-wc-subscriptions-switcher.php:409 +#: includes/class-wc-subscriptions-switcher.php:1817 +msgid "Upgrade or Downgrade" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:795 +msgid "Switch Order" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:810 +msgid "Switched Subscription" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:910 +msgid "We can not find your old subscription item." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:924 +msgid "You can not switch to the same subscription." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:971 +msgid "" +"You can not switch this subscription. It appears you do not own the " +"subscription." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:1006 +msgid "There was an error locating the switch details." +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:47 +#: templates/admin/deprecated/html-variation-synchronisation.php:30 +msgid "Synchronise Renewals" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:48 +msgid "" +"Align the payment date for all customers who purchase this subscription to " +"a specific day of the week or month." +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:149 +msgid "Synchronisation" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:158 +msgid "Align Subscription Renewal Day" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:165 +msgid "Prorate First Payment" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:166 +msgid "" +"If a subscription is synchronised to a specific day of the week, month or " +"year, charge a prorated amount for the subscription at the time of sign up." +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:607 +msgid "Do not synchronise" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:615 +#. translators: placeholder is a day of the week +msgid "%s each week" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:621 +#. translators: placeholder is a number of day with language specific suffix +#. applied (e.g. "1st", "3rd", "5th", etc...) +msgid "%s day of the month" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:623 +msgid "Last day of the month" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:671 +msgid "Today!" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:678 +#. translators: placeholder is a date +msgid "First payment prorated. Next payment: %s" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:681 +#. translators: placeholder is a date +msgid "First payment: %s" +msgstr "" + +#: includes/class-wcs-auth.php:39 +msgid "View subscriptions" +msgstr "" + +#: includes/class-wcs-auth.php:42 +msgid "Create subscriptions" +msgstr "" + +#: includes/class-wcs-auth.php:45 +msgid "View and manage subscriptions" +msgstr "" + +#: includes/class-wcs-cart-initial-payment.php:48 +msgid "That doesn't appear to be your order." +msgstr "" + +#: includes/class-wcs-cart-renewal.php:162 +#. translators: placeholder is an item name +msgid "" +"The %s product has been deleted and can no longer be renewed. Please choose " +"a new product or contact us for assistance." +msgstr "" + +#: includes/class-wcs-cart-renewal.php:191 +#. translators: %s is subscription's number +msgid "Subscription #%s has not been added to the cart." +msgstr "" + +#: includes/class-wcs-cart-renewal.php:317 +msgid "" +"We couldn't find the original subscription for an item in your cart. The " +"item was removed." +msgid_plural "" +"We couldn't find the original subscriptions for items in your cart. The " +"items were removed." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-wcs-cart-renewal.php:324 +msgid "" +"We couldn't find the original renewal order for an item in your cart. The " +"item was removed." +msgid_plural "" +"We couldn't find the original renewal orders for items in your cart. The " +"items were removed." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-wcs-cart-renewal.php:534 +msgid "All linked subscription items have been removed from the cart." +msgstr "" + +#: includes/class-wcs-cart-resubscribe.php:50 +msgid "There was an error with your request to resubscribe. Please try again." +msgstr "" + +#: includes/class-wcs-cart-resubscribe.php:54 +msgid "That subscription does not exist. Has it been deleted?" +msgstr "" + +#: includes/class-wcs-cart-resubscribe.php:62 +msgid "" +"You can not resubscribe to that subscription. Please contact us if you need " +"assistance." +msgstr "" + +#: includes/class-wcs-cart-resubscribe.php:71 +#: includes/class-wcs-cart-resubscribe.php:88 +msgid "Complete checkout to resubscribe." +msgstr "" + +#: includes/class-wcs-change-payment-method-admin.php:113 +msgid "Please choose a valid payment gateway to change to." +msgstr "" + +#: includes/class-wcs-remove-item.php:106 +msgid "Your request to undo your previous action was unsuccessful." +msgstr "" + +#: includes/class-wcs-remove-item.php:127 +#. translators: placeholders are 1$: item name, and, 2$: opening and, 3$: +#. closing link tags +msgid "You have successfully removed \"%1$s\" from your subscription. %2$sUndo?%3$s" +msgstr "" + +#: includes/class-wcs-remove-item.php:156 +#: includes/class-wcs-user-change-status-handler.php:99 +msgid "Security error. Please contact us if you need assistance." +msgstr "" + +#: includes/class-wcs-remove-item.php:160 +msgid "You cannot modify a subscription that does not belong to you." +msgstr "" + +#: includes/class-wcs-remove-item.php:164 +msgid "You cannot remove an item that does not exist. " +msgstr "" + +#: includes/class-wcs-remove-item.php:168 +msgid "" +"The item was not removed because this Subscription's payment method does " +"not support removing an item." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:60 +msgid "" +"You can not reactivate that subscription until paying to renew it. Please " +"contact us if you need assistance." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:70 +msgid "" +"You can not suspend that subscription - the suspension limit has been " +"reached. Please contact us if you need assistance." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:95 +msgid "That subscription does not exist. Please contact us if you need assistance." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:108 +#. translators: placeholder is subscription's new status, translated +msgid "" +"That subscription can not be changed to %s. Please contact us if you need " +"assistance." +msgstr "" + +#: includes/class-wcs-webhooks.php:83 +msgid " Subscription Created" +msgstr "" + +#: includes/class-wcs-webhooks.php:84 +msgid " Subscription Updated" +msgstr "" + +#: includes/class-wcs-webhooks.php:85 +msgid " Subscription Deleted" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:27 +msgid "Cancelled Subscription" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:28 +msgid "" +"Cancelled Subscription emails are sent when a customer's subscription is " +"cancelled (either by a store manager, or the customer)." +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:30 +msgid "Subscription Cancelled" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:122 +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:168 +msgid "Enable this email notification" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:129 +#. translators: placeholder is admin email +msgid "" +"Enter recipients (comma separated) for this email. Defaults to " +"%s." +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:136 +msgid "" +"This controls the email subject line. Leave blank to use the default " +"subject: %s." +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:143 +msgid "" +"This controls the main heading contained within the email notification. " +"Leave blank to use the default heading: %s." +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:150 +msgid "Choose which format of email to send." +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-renewal-order.php:25 +msgid "Completed Renewal Order" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-renewal-order.php:26 +msgid "" +"Renewal order complete emails are sent to the customer when a subscription " +"renewal order is marked complete and usually indicates that the item for " +"that renewal period has been shipped." +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-switch-order.php:26 +msgid "Subscription Switch Complete" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-switch-order.php:27 +msgid "" +"Subscription switch complete emails are sent to the customer when a " +"subscription is switched successfully." +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-switch-order.php:30 +msgid "Your subscription change is complete" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-switch-order.php:31 +msgid "Your {blogname} subscription change from {order_date} is complete" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-switch-order.php:38 +msgid "Your subscription change is complete - download your files" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-switch-order.php:39 +msgid "" +"Your {blogname} subscription change from {order_date} is complete - " +"download your files" +msgstr "" + +#: includes/emails/class-wcs-email-customer-processing-renewal-order.php:24 +msgid "Processing Renewal order" +msgstr "" + +#: includes/emails/class-wcs-email-customer-processing-renewal-order.php:25 +msgid "" +"This is an order notification sent to the customer after payment for a " +"subscription renewal order is completed. It contains the renewal order " +"details." +msgstr "" + +#: includes/emails/class-wcs-email-customer-processing-renewal-order.php:28 +msgid "Thank you for your order" +msgstr "" + +#: includes/emails/class-wcs-email-customer-processing-renewal-order.php:29 +msgid "Your {blogname} renewal order receipt from {order_date}" +msgstr "" + +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:27 +msgid "Customer Renewal Invoice" +msgstr "" + +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:28 +msgid "" +"Sent to a customer when the subscription is due for renewal and the renewal " +"requires a manual payment, either because it uses manual renewals or the " +"automatic recurring payment failed. The email contains renewal order " +"information and payment links." +msgstr "" + +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:35 +msgid "Invoice for renewal order {order_number} from {order_date}" +msgstr "" + +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:36 +msgid "Invoice for renewal order {order_number}" +msgstr "" + +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:38 +msgid "Your {blogname} renewal order from {order_date}" +msgstr "" + +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:39 +msgid "Renewal order {order_number} details" +msgstr "" + +#: includes/emails/class-wcs-email-new-renewal-order.php:22 +msgid "New Renewal Order" +msgstr "" + +#: includes/emails/class-wcs-email-new-renewal-order.php:23 +msgid "" +"New renewal order emails are sent when a subscription renewal payment is " +"processed." +msgstr "" + +#: includes/emails/class-wcs-email-new-renewal-order.php:25 +msgid "New subscription renewal order" +msgstr "" + +#: includes/emails/class-wcs-email-new-renewal-order.php:26 +msgid "[{blogname}] New subscription renewal order ({order_number}) - {order_date}" +msgstr "" + +#: includes/emails/class-wcs-email-new-switch-order.php:22 +#: includes/emails/class-wcs-email-new-switch-order.php:25 +msgid "Subscription Switched" +msgstr "" + +#: includes/emails/class-wcs-email-new-switch-order.php:23 +msgid "" +"Subscription switched emails are sent when a customer switches a " +"subscription." +msgstr "" + +#: includes/emails/class-wcs-email-new-switch-order.php:26 +msgid "[{blogname}] Subscription Switched ({order_number}) - {order_date}" +msgstr "" + +#: includes/gateways/class-wc-subscriptions-payment-gateways.php:126 +msgid "" +"Sorry, it seems there are no available payment methods which support " +"subscriptions. Please contact us if you require assistance or wish to make " +"alternate arrangements." +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:199 +msgid "Unable to find order for PayPal billing agreement." +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:256 +msgid "An error occurred, please try again or try an alternate form of payment." +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:359 +#. translators: placeholders are PayPal API error code and PayPal API error +#. message +msgid "PayPal API error: (%d) %s" +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:364 +#. translators: placeholder is PayPal transaction status message +msgid "PayPal Transaction Held: %s" +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:376 +#. translators: placeholder is PayPal transaction status message +msgid "PayPal payment declined: %s" +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:380 +msgid "PayPal payment approved (ID: %s)" +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:433 +msgid "" +"Are you sure you want to change the payment method from PayPal standard?\n" +"\n" +"This will suspend the subscription at PayPal." +msgstr "" + +#: includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php:53 +msgid "" +"It is strongly recommended you do not change the Receiver Email " +"address if you have active subscriptions with PayPal. Doing so can " +"break existing subscriptions." +msgstr "" + +#: includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php:106 +#. translators: placeholders are opening and closing link tags. 1$-2$: to docs +#. on woothemes, 3$-4$ to gateway settings on the site +msgid "" +"PayPal is inactive for subscription transactions. Please %1$sset up the " +"PayPal IPN%2$s and %3$senter your API credentials%4$s to enable PayPal for " +"Subscriptions." +msgstr "" + +#: includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php:119 +#. translators: placeholders are opening and closing strong and link tags. +#. 1$-2$: strong tags, 3$-8$ link to docs on woothemes +msgid "" +"%1$sPayPal Reference Transactions are not enabled on your account%2$s, some " +"subscription management features are not enabled. Please contact PayPal and " +"request they %3$senable PayPal Reference Transactions%4$s on your account. " +"%5$sCheck PayPal Account%6$s %7$sLearn more %8$s" +msgstr "" + +#: includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php:137 +#. translators: placeholders are opening and closing strong tags. +msgid "" +"%1$sPayPal Reference Transactions are enabled on your account%2$s. All " +"subscription management features are now enabled. Happy selling!" +msgstr "" + +#: includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php:148 +#. translators: placeholders are link opening and closing tags. 1$-2$: to +#. gateway settings, 3$-4$: support docs on woothemes.com +msgid "" +"There is a problem with PayPal. Your API credentials may be incorrect. " +"Please update your %1$sAPI credentials%2$s. %3$sLearn more%4$s." +msgstr "" + +#: includes/gateways/paypal/includes/admin/class-wcs-paypal-admin.php:161 +#. translators: placeholders are opening and closing link tags. 1$-2$: docs on +#. woothemes, 3$-4$: dismiss link +msgid "" +"There is a problem with PayPal. Your PayPal account is issuing out-of-date " +"subscription IDs. %1$sLearn more%2$s. %3$sDismiss%4$s." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:274 +msgid "Total Discount" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:302 +#. translators: placeholder is blogname +msgid "%s - Order" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:513 +msgid "SKU: %s" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response-payment.php:120 +#. translators: placeholder is localised datetime +msgid "expected clearing date %s" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-ipn-handler.php:114 +msgid "Billing agreement cancelled at PayPal." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:259 +msgid "IPN subscription sign up completed." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:299 +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:376 +msgid "IPN subscription payment completed." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:338 +msgid "IPN subscription failing payment method changed." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:416 +msgid "IPN subscription suspended." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:439 +msgid "IPN subscription cancelled." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:455 +msgid "IPN subscription payment failure." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-status-manager.php:42 +msgid "Subscription cancelled with PayPal" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-status-manager.php:53 +msgid "Subscription suspended with PayPal" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-status-manager.php:66 +msgid "Subscription reactivated with PayPal" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:247 +#. translators: placeholder is a list of version numbers (e.g. "1.3 & 1.4 & +#. 1.5") +msgid "Database updated to version %s" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:270 +#. translators: 1$: number of action scheduler hooks upgraded, 2$: +#. "{execution_time}", will be replaced on front end with actual time +msgid "" +"Migrated %1$s subscription related hooks to the new scheduler (in %2$s " +"seconds)." +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:286 +#. translators: 1$: number of subscriptions upgraded, 2$: "{execution_time}", +#. will be replaced on front end with actual time it took +msgid "Migrated %1$s subscriptions to the new structure (in %2$s seconds)." +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:299 +#. translators: 1$: error message, 2$: opening link tag, 3$: closing link tag +msgid "" +"Unable to upgrade subscriptions.
    Error: %1$s
    Please refresh the " +"page and try again. If problem persists, %2$scontact support%3$s." +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:554 +msgid "Welcome to WooCommerce Subscriptions 2.0" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:554 +msgid "About WooCommerce Subscriptions" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:20 +msgid "Welcome to Subscriptions 2.0" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:23 +msgid "Thank you for updating to the latest version of WooCommerce Subscriptions." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:24 +msgid "" +"Version 2.0 has been in development for more than a year. We've reinvented " +"the extension to take into account 3 years of feedback from store managers." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:25 +msgid "We hope you enjoy it!" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:31 +#. translators: placeholder is version number +msgid "Version %s" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:42 +msgid "Check Out What's New" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:51 +msgid "Multiple Subscriptions" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:52 +msgid "It's now easier for your customers to buy more subscriptions!" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:53 +msgid "" +"Customers can now purchase different subscription products in one " +"transaction. The products can bill on any schedule and have any combination " +"of sign-up fees and/or free trials." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:56 +#. translators: placeholders are opening and closing link tags +msgid "Learn more about the new %smultiple subscriptions%s feature." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:68 +msgid "New Add/Edit Subscription Screen" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:69 +msgid "" +"Subscriptions v2.0 introduces a new administration interface to add or edit " +"a subscription. You can make all the familiar changes, like modifying " +"recurring totals or subscription status. You can also make some new " +"modifications, like changing the expiration date, adding a shipping cost or " +"adding a product line item." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:72 +#. translators: placeholders are opening and closing tags +msgid "" +"The new interface is also built on the existing %sEdit Order%s screen. If " +"you've ever modified an order, you already know how to modify a " +"subscription." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:76 +#. translators: placeholers are link tags: 1$-2$ new subscription page, 3$-4$: +#. docs on woothemes +msgid "" +"%1$sAdd a subscription%2$s now or %3$slearn more%4$s about the new " +"interface." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:87 +msgid "New View Subscription Page" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:91 +#. translators: placeholders are opening and closing tags +msgid "" +"Your customers can now view the full details of a subscription, including " +"line items, billing and shipping address, billing schedule and renewal " +"orders, from a special %sMy Account > View Subscription%s page." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:93 +msgid "" +"This new page is also where the customer can suspend or cancel their " +"subscription, change payment method, change shipping address or " +"upgrade/downgrade an item." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:97 +#. translators: placeholders are opening and closing link tags +msgid "Learn more about the new %sView Subscription page%s." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:111 +#. translators: placeholders are for opening and closing link () tags +msgid "" +"By default, adding new files to an existing subscription product will " +"automatically provide active subscribers with access to the new files. " +"However, now you can enable a %snew content dripping setting%s to provide " +"subscribers with access to new files only after the next renewal payment." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:115 +#: includes/upgrades/templates/wcs-about.php:128 +#: includes/upgrades/templates/wcs-about.php:141 +#. translators: placeholders are for opening and closing link () tags +msgid "%sLearn more »%s" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:124 +#. translators: placeholders are opening and closing tags +msgid "" +"For a store manager to change a subscription from automatic to manual " +"renewal payments (or manual to automatic) with Subscriptions v1.5, the " +"database needed to be modified directly. Subscriptions now provides a way " +"for payment gateways to allow you to change that from the new %sEdit " +"Subscription%s interface." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:134 +msgid "Change Trial and End Dates" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:137 +#. translators: placeholders are opening and closing tags +msgid "" +"It was already possible to change a subscription's next payment date, but " +"some store managers wanted to provide a customer with an extended free " +"trial or add an extra month to the expiration date. Now you can change all " +"of these dates from the %sEdit Subscription%s screen." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:150 +msgid "And much more..." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:157 +msgid "Peek Under the Hood for Developers" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:158 +msgid "" +"Subscriptions 2.0 introduces a new architecture built on the WooCommerce " +"Custom Order Types API." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:164 +#. translators: placeholders are opening and closing code tags +msgid "New %sshop_subscription%s Post Type" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:166 +msgid "" +"By making a subscription a Custom Order Type, a subscription is also now a " +"custom post type. This makes it faster to query subscriptions and it uses a " +"database schema that is as scalable as WordPress posts and pages." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:169 +#. translators: placeholders are opening and closing tags +msgid "" +"Developers can also now use all the familiar WordPress functions, like " +"%sget_posts()%s, to query or modify subscription data." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:175 +#. translators: placeholders are opening and closing tags +msgid "New %sWC_Subscription%s Object" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:177 +msgid "" +"Subscriptions 2.0 introduces a new object for working with a subscription " +"at the application level. The cumbersome APIs for retrieving or modifying a " +"subscription's data are gone!" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:180 +#. translators: all placeholders are opening and closing tags, no need +#. to order them +msgid "" +"Because the %sWC_Subscription%s class extends %sWC_Order%s, you can use its " +"familiar methods, like %s$subscription->update_status()%s or " +"%s$subscription->get_total()%s." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:184 +msgid "REST API Endpoints" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:185 +msgid "" +"We didn't just improve interfaces for humans, we also improved them for " +"computers. Your applications can now create, read, update or delete " +"subscriptions via RESTful API endpoints." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:188 +#. translators: all placeholders are opening and closing tags, no need +#. to order them +msgid "" +"Want to list all the subscriptions on a site? Get " +"%sexample.com/wc-api/v2/subscriptions/%s. Want the details of a specific " +"subscription? Get %s/wc-api/v2/subscriptions//%s." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:194 +msgid "Go to WooCommerce Subscriptions Settings" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade-in-progress.php:24 +msgid "WooCommerce Subscriptions Update in Progress" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade-in-progress.php:30 +msgid "The Upgrade is in Progress" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade-in-progress.php:31 +msgid "" +"The WooCommerce Subscriptions plugin is currently running its database " +"upgrade routine." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade-in-progress.php:34 +#. translators: placeholder is number of seconds +msgid "" +"If you received a server error and reloaded the page to find this notice, " +"please refresh the page in %s seconds and the upgrade routine will " +"recommence without issues." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade-in-progress.php:36 +msgid "" +"Rest assured, although the update process may take a little while, it is " +"coded to prevent defects, your site is safe and will be up and running " +"again, faster than ever, shortly." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:19 +msgid "WooCommerce Subscriptions Update" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:29 +msgid "Database Update Required" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:30 +msgid "The WooCommerce Subscriptions plugin has been updated!" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:33 +#. translators: placeholders are opening and closing tags +msgid "" +"Before we send you on your way, we need to update your database to the " +"newest version. If you do not have a recent backup of your site, %snow is " +"the time to create one%s." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:38 +#. translators: 1$: number of subscriptions on site, 2$, lower estimate +#. (minutes), 3$: upper estimate +msgid "" +"The full update process for the %1$d subscriptions on your site will take " +"between %2$d and %3$d minutes." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:41 +msgid "The update process may take a little while, so please be patient." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:43 +msgid "" +"Customers and other non-administrative users can browse and purchase from " +"your store without interuption while the update is in progress." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:49 +msgid "Update in Progress" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:50 +msgid "" +"This page will display the results of the process as each batch of " +"subscriptions is updated." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:51 +msgid "" +"Please keep this page open until the update process completes. No need to " +"refresh or restart the process." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:53 +msgid "" +"Remember, although the update process may take a while, customers and other " +"non-administrative users can browse and purchase from your store without " +"interuption while the update is in progress." +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:61 +msgid "Update Complete" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:62 +msgid "Your database has been updated successfully!" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:63 +msgid "Continue" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:66 +#. translators: $1: placeholder is number of weeks, 2$: path to the file +msgid "" +"To record the progress of the update a new log file was created. This file " +"will be automatically deleted in %1$d weeks. If you would like to delete it " +"sooner, you can find it here: %2$s" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:71 +msgid "Update Error" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:72 +msgid "There was an error with the update. Please refresh the page and try again." +msgstr "" + +#: includes/wcs-cart-functions.php:72 +msgid "Shipping via %s" +msgstr "" + +#: includes/wcs-cart-functions.php:91 +msgid "Shipping" +msgid_plural "Shipping %d" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-cart-functions.php:219 +msgid "Free shipping coupon" +msgstr "" + +#: includes/wcs-cart-functions.php:322 +#. translators: placeholder is a date +msgid "First renewal: %s" +msgstr "" + +#: includes/wcs-deprecated-functions.php:113 +#. translators: placeholder is either subscription key or a subscription id, +#. or, failing that, empty (e.g. "145_21" or "145") +msgid "" +"Could not get subscription. Most likely the subscription key does not refer " +"to a subscription. The key was: \"%s\"." +msgstr "" + +#: includes/wcs-formatting-functions.php:85 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount string (e.g. "£10 / month" ) +msgid "%1$s %2$s then %3$s" +msgstr "" + +#: includes/wcs-formatting-functions.php:99 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount string, 4$: payment day of the week (e.g. "$15 up +#. front, then $10 every Wednesday") +msgid "%1$s %2$s then %3$s every %4$s" +msgstr "" + +#: includes/wcs-formatting-functions.php:108 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front" ), +#. 3$: recurring amount, 4$: interval (e.g. "2nd week"), 5$: day of the week +#. (e.g. "Thursday"); (e.g. "$10 up front, then $20 every 2nd week on +#. Wednesday") +msgid "%1$s %2$s then %3$s every %4%s on %5$s" +msgstr "" + +#: includes/wcs-formatting-functions.php:121 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount; (e.g. "$10 up front then $30 on the last day of each +#. month") +msgid "%1$s %2$s then %3$s on the last day of each month" +msgstr "" + +#: includes/wcs-formatting-functions.php:124 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount, 4$: day of the month (e.g. "23rd"); (e.g. "$10 up +#. front then $40 on the 23rd of each month") +msgid "%1$s %2$s then %3$s on the %4$s of each month" +msgstr "" + +#: includes/wcs-formatting-functions.php:140 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount, 4$: interval (e.g. "3rd") +msgid "%1$s %2$s then %3$s on the last day of every %4$s month" +msgstr "" + +#: includes/wcs-formatting-functions.php:143 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount, 4$: day of the month (e.g. "23rd"), 5$: interval (e.g. +#. "3rd") +msgid "%1$s %2$s then %3$s on the %4$s day of every %5$s month" +msgstr "" + +#: includes/wcs-formatting-functions.php:161 +#. translators: 1$: initial amount, 2$: intial description (e.g. "up front"), +#. 3$: recurring amount, 4$: month of year (e.g. "March"), 5$: day of the month +#. (e.g. "23rd") +msgid "%1$s %2$s then %3$s on %4$s %5$s each year" +msgstr "" + +#: includes/wcs-formatting-functions.php:170 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount, 4$: month (e.g. "March"), 5$: day of the month (e.g. +#. "23rd"), 6$: interval (e.g. "3rd") +msgid "%1$s %2$s then %3$s on %4$s %5$s every %6$s year" +msgstr "" + +#: includes/wcs-formatting-functions.php:180 +#. translators: 1$: initial amount, 2$: initial description (e.g. "up front"), +#. 3$: recurring amount, 4$: subscription period (e.g. "month" or "3 months") +msgid "%1$s %2$s then %3$s / %4$s" +msgid_plural "%1$s %2$s then %3$s every %4$s" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-formatting-functions.php:201 +#. translators: 1$: subscription string (e.g. "$10 up front then $5 on March +#. 23rd every 3rd year"), 2$: trial length (e.g. "3 weeks") +msgid "%1$s after %2$s free trial" +msgstr "" + +#: includes/wcs-formatting-functions.php:204 +#. translators: 1$: trial length (e.g. "3 weeks"), 2$: subscription string +#. (e.g. "$10 up front then $5 on March 23rd every 3rd year") +msgid "%1$s free trial then %2$s" +msgstr "" + +#: includes/wcs-helper-functions.php:38 +#. translators: date placeholder for input, javascript format +msgid "YYYY-MM-DD" +msgstr "" + +#: includes/wcs-helper-functions.php:43 +#. translators: hour placeholder for time input, javascript format +msgid "HH" +msgstr "" + +#: includes/wcs-helper-functions.php:46 +#. translators: minute placeholder for time input, javascript format +msgid "MM" +msgstr "" + +#: includes/wcs-order-functions.php:267 +msgid "Subscription Renewal Order – %s" +msgstr "" + +#: includes/wcs-order-functions.php:270 +msgid "Resubscribe Order – %s" +msgstr "" + +#: includes/wcs-order-functions.php:289 +msgid "$type passed to the function was not a string." +msgstr "" + +#: includes/wcs-order-functions.php:294 +msgid "\"%s\" is not a valid new order type." +msgstr "" + +#: includes/wcs-order-functions.php:396 +msgid "Invalid data. No valid subscription / order was passed in." +msgstr "" + +#: includes/wcs-order-functions.php:400 +msgid "Invalid data. No valid item id was passed in." +msgstr "" + +#: includes/wcs-time-functions.php:54 +msgid "%s day" +msgid_plural "a %s-day" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:55 +msgid "%s week" +msgid_plural "a %s-week" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:56 +msgid "%s month" +msgid_plural "a %s-month" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:57 +msgid "%s year" +msgid_plural "a %s-year" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-user-functions.php:279 +#: templates/single-product/add-to-cart/variable-subscription.php:30 +msgid "Resubscribe" +msgstr "" + +#: templates/admin/html-variation-price.php:20 +msgid "Sign-up Fee: (%s)" +msgstr "" + +#: templates/admin/html-variation-price.php:25 +msgid "Free Trial:" +msgstr "" + +#: templates/admin/html-variation-price.php:32 +msgid "Subscription Trial Period:" +msgstr "" + +#: templates/admin/html-variation-price.php:49 +msgid "Billing Period:" +msgstr "" + +#: templates/admin/html-variation-price.php:56 +msgid "Billing Interval:" +msgstr "" + +#: templates/admin/html-variation-price.php:64 +msgid "Subscription Length:" +msgstr "" + +#: templates/admin/post-types/writepanels/order-shipping-html.php:8 +msgid "Label" +msgstr "" + +#: templates/admin/post-types/writepanels/order-shipping-html.php:13 +msgid "Shipping Method" +msgstr "" + +#: templates/admin/post-types/writepanels/order-shipping-html.php:34 +#: templates/admin/post-types/writepanels/order-shipping-html.php:36 +msgid "Other" +msgstr "" + +#: templates/admin/post-types/writepanels/order-tax-html.php:17 +msgid "Recurring Sales Tax:" +msgstr "" + +#: templates/admin/post-types/writepanels/order-tax-html.php:21 +msgid "Shipping Tax:" +msgstr "" + +#: templates/cart/cart-recurring-shipping.php:19 +msgid "Recurring shipping options can be selected on checkout." +msgstr "" + +#: templates/cart/cart-recurring-shipping.php:33 +msgid "Shipping costs will be calculated once you have provided your address." +msgstr "" + +#: templates/cart/cart-recurring-shipping.php:35 +msgid "" +"There are no shipping methods available. Please double check your address, " +"or contact us if you need any help." +msgstr "" + +#: templates/checkout/form-change-payment-method.php:82 +msgid "" +"Sorry, it seems no payment gateways support changing the recurring payment " +"method. Please contact us if you require assistance or to make alternate " +"arrangements." +msgstr "" + +#: templates/checkout/recurring-totals.php:19 +msgid "Recurring Totals" +msgstr "" + +#: templates/checkout/recurring-totals.php:28 +msgid "Subtotal" +msgstr "" + +#: templates/checkout/recurring-totals.php:105 +msgid "Recurring Total" +msgstr "" + +#: templates/emails/admin-new-renewal-order.php:65 +#: templates/emails/customer-processing-renewal-order.php:65 +#: templates/emails/plain/admin-new-renewal-order.php:50 +#: templates/myaccount/view-subscription.php:241 +msgid "Customer details" +msgstr "" + +#: templates/emails/admin-new-renewal-order.php:71 +#: templates/emails/customer-processing-renewal-order.php:71 +#. translators: $1: opening tag, $2: closing tag, $3: billing +#. email +msgid "%1$sEmail:%2$s %3$s" +msgstr "" + +#: templates/emails/admin-new-renewal-order.php:79 +#: templates/emails/customer-processing-renewal-order.php:79 +#. translators: $1: opening tag, $2: closing tag, $3: billing +#. phone +msgid "%1$sTel:%2$s %3$s" +msgstr "" + +#: templates/emails/admin-new-switch-order.php:24 +msgid "Switch Order Details" +msgstr "" + +#: templates/emails/admin-new-switch-order.php:29 +#: templates/emails/customer-completed-switch-order.php:30 +#: templates/emails/customer-renewal-invoice.php:35 +#. translators: placeholder is the order's number +msgid "Order: %s" +msgstr "" + +#: templates/emails/admin-new-switch-order.php:67 +#: templates/emails/customer-completed-switch-order.php:66 +msgid "New Subscription Details" +msgstr "" + +#: templates/emails/admin-new-switch-order.php:71 +#: templates/emails/customer-completed-switch-order.php:70 +msgid "Subscription %s" +msgstr "" + +#: templates/emails/cancelled-subscription.php:19 +#: templates/emails/plain/cancelled-subscription.php:16 +#. translators: $1: customer's billing first name, $2: customer's billing last +#. name +msgid "" +"A subscription belonging to %1$s %2$s has been cancelled. Their " +"subscription's details are as follows:" +msgstr "" + +#: templates/emails/customer-completed-renewal-order.php:20 +#: templates/emails/plain/customer-completed-renewal-order.php:16 +#. translators: placeholder is the name of the site +msgid "" +"Hi there. Your subscription renewal order with %s has been completed. Your " +"order details are shown below for your reference:" +msgstr "" + +#: templates/emails/customer-completed-switch-order.php:20 +#: templates/emails/plain/customer-completed-switch-order.php:16 +#. translators: placeholder is the name of the site +msgid "" +"Hi there. You have successfully changed your subscription items on %s. Your " +"new order and subscription details are shown below for your reference:" +msgstr "" + +#: templates/emails/customer-completed-switch-order.php:26 +msgid "Order Details" +msgstr "" + +#: templates/emails/customer-processing-renewal-order.php:17 +#: templates/emails/plain/customer-processing-renewal-order.php:15 +msgid "" +"Your subscription renewal order has been received and is now being " +"processed. Your order details are shown below for your reference:" +msgstr "" + +#: templates/emails/customer-renewal-invoice.php:20 +#: templates/emails/customer-renewal-invoice.php:27 +msgid "Pay Now »" +msgstr "" + +#: templates/emails/plain/admin-new-renewal-order.php:24 +#: templates/emails/plain/admin-new-switch-order.php:24 +#: templates/emails/plain/customer-completed-renewal-order.php:22 +#: templates/emails/plain/customer-completed-switch-order.php:22 +#: templates/emails/plain/customer-processing-renewal-order.php:21 +#: templates/emails/plain/customer-renewal-invoice.php:27 +msgid "Order number: %s" +msgstr "" + +#: templates/emails/plain/admin-new-renewal-order.php:25 +#: templates/emails/plain/customer-completed-renewal-order.php:23 +#: templates/emails/plain/customer-completed-switch-order.php:23 +#: templates/emails/plain/customer-processing-renewal-order.php:22 +#: templates/emails/plain/customer-renewal-invoice.php:28 +msgid "Order date: %s" +msgstr "" + +#: templates/emails/plain/admin-new-switch-order.php:47 +#. translators: placeholder is edit post link for the order +msgid "View order: %s" +msgstr "" + +#: templates/emails/plain/cancelled-subscription.php:22 +#. translators: placeholder is last time subscription was paid +msgid "Last Payment: %s" +msgstr "" + +#: templates/emails/plain/cancelled-subscription.php:28 +#. translators: placeholder is localised date string +msgid "End of Prepaid Term: %s" +msgstr "" + +#: templates/emails/plain/customer-completed-renewal-order.php:48 +#: templates/emails/plain/customer-processing-renewal-order.php:47 +msgid "Your details" +msgstr "" + +#: templates/emails/plain/customer-completed-switch-order.php:45 +#. translators: placeholder is order's view url +msgid "View your order: %s" +msgstr "" + +#: templates/emails/plain/customer-completed-switch-order.php:70 +#. translators: placeholder is subscription's view url +msgid "View your subscription: %s" +msgstr "" + +#: templates/emails/plain/subscription-info.php:16 +#: templates/emails/subscription-info.php:14 +msgid "Subscription Information:" +msgstr "" + +#: templates/myaccount/my-subscriptions.php:17 +msgid "My Subscriptions" +msgstr "" + +#: templates/myaccount/my-subscriptions.php:36 +#: templates/myaccount/related-subscriptions.php:30 +msgid "ID" +msgstr "" + +#: templates/myaccount/my-subscriptions.php:71 +#. translators: placeholders are opening and closing link tags to take to the +#. shop page +msgid "" +"You have no active subscriptions. Find your first subscription in the " +"%sstore%s." +msgstr "" + +#: templates/myaccount/related-orders.php:22 +msgid "Order" +msgstr "" + +#: templates/myaccount/related-orders.php:50 +#. translators: $1: formatted order total for the order, $2: number of items +#. bought +msgid "%1$s for %2$d item" +msgid_plural "%1$s for %2$d items" +msgstr[0] "" +msgstr[1] "" + +#: templates/myaccount/related-subscriptions.php:15 +msgid "Related Subscriptions" +msgstr "" + +#: templates/myaccount/view-subscription.php:20 +msgid "My Account" +msgstr "" + +#: templates/myaccount/view-subscription.php:56 +msgid "Actions" +msgstr "" + +#: templates/myaccount/view-subscription.php:67 +msgid "Subscription Updates" +msgstr "" + +#: templates/myaccount/view-subscription.php:86 +msgid "Subscription Totals" +msgstr "" + +#: templates/myaccount/view-subscription.php:108 +msgid "Are you sure you want remove this item from your subscription?" +msgstr "" + +#: templates/myaccount/view-subscription.php:222 +msgid "Refunded:" +msgstr "" + +#: templates/myaccount/view-subscription.php:269 wcs-functions.php:254 +msgid "Billing Address" +msgstr "" + +#: templates/myaccount/view-subscription.php:288 wcs-functions.php:253 +msgid "Shipping Address" +msgstr "" + +#: templates/single-product/add-to-cart/subscription.php:42 +msgid "Renew" +msgstr "" + +#: templates/single-product/add-to-cart/subscription.php:45 +#: templates/single-product/add-to-cart/variable-subscription.php:33 +msgid "You have an active subscription to this product already." +msgstr "" + +#: templates/single-product/add-to-cart/variable-subscription.php:24 +msgid "This product is currently out of stock and unavailable." +msgstr "" + +#: templates/single-product/add-to-cart/variable-subscription.php:45 +msgid "Clear selection" +msgstr "" + +#: wcs-functions.php:226 +msgid "Can not get status name. Status is not a string." +msgstr "" + +#: wcs-functions.php:249 +msgid "Can not get address type display name. Address type is not a string." +msgstr "" + +#: wcs-functions.php:290 +msgid "Date type is not a string." +msgstr "" + +#: wcs-functions.php:292 +msgid "Date type can not be an empty string." +msgstr "" + +#: woocommerce-subscriptions.php:215 +msgid "This is where subscriptions are stored." +msgstr "" + +#: woocommerce-subscriptions.php:259 +msgid "No Subscriptions found" +msgstr "" + +#: woocommerce-subscriptions.php:261 +msgid "" +"Subscriptions will appear here for you to view and manage once purchased by " +"a customer." +msgstr "" + +#: woocommerce-subscriptions.php:263 +#. translators: placeholders are opening and closing link tags +msgid "%sLearn more about managing subscriptions »%s" +msgstr "" + +#: woocommerce-subscriptions.php:265 +#. translators: placeholders are opening and closing link tags +msgid "%sAdd a subscription product »%s" +msgstr "" + +#: woocommerce-subscriptions.php:379 +msgid "" +"A subscription renewal has been removed from your cart. Multiple " +"subscriptions can not be purchased at the same time." +msgstr "" + +#: woocommerce-subscriptions.php:385 +msgid "" +"A subscription has been removed from your cart. Due to payment gateway " +"restrictions, different subscription products can not be purchased at the " +"same time." +msgstr "" + +#: woocommerce-subscriptions.php:391 +msgid "" +"A subscription has been removed from your cart. Products and subscriptions " +"can not be purchased at the same time." +msgstr "" + +#: woocommerce-subscriptions.php:525 woocommerce-subscriptions.php:542 +#. translators: placeholder is a number, this is for the teens +#. translators: placeholder is a number, numbers ending in 4-9, 0 +msgid "%sth" +msgstr "" + +#: woocommerce-subscriptions.php:530 +#. translators: placeholder is a number, numbers ending in 1 +msgid "%sst" +msgstr "" + +#: woocommerce-subscriptions.php:534 +#. translators: placeholder is a number, numbers ending in 2 +msgid "%snd" +msgstr "" + +#: woocommerce-subscriptions.php:538 +#. translators: placeholder is a number, numbers ending in 3 +msgid "%srd" +msgstr "" + +#: woocommerce-subscriptions.php:568 +#. translators: 1$-2$: opening and closing tags, 3$-4$: link tags, +#. takes to woocommerce plugin on wp.org, 5$-6$: opening and closing link tags, +#. leads to plugins.php in admin +msgid "" +"%1$sWooCommerce Subscriptions is inactive.%2$s The %3$sWooCommerce " +"plugin%4$s must be active for WooCommerce Subscriptions to work. Please " +"%5$sinstall & activate WooCommerce »%6$s" +msgstr "" + +#: woocommerce-subscriptions.php:575 +#. translators: 1$-2$: opening and closing tags, 3$-4$: opening and +#. closing link tags, leads to plugin admin +msgid "" +"%1$sWooCommerce Subscriptions is inactive.%2$s This version of " +"Subscriptions requires WooCommerce 2.3 or newer. Please %3$supdate " +"WooCommerce to version 2.3 or newer »%4$s" +msgstr "" + +#: woocommerce-subscriptions.php:746 +#. translators: 1$-2$: opening and closing tags, 3$-4$: opening and +#. closing link tags, leads to plugin admin +msgid "" +"%1$sYou have an out-of-date version of WooCommerce installed%2$s. " +"WooCommerce Subscriptions no longer supports versions of WooCommerce prior " +"to 2.3. Please %3$supgrade WooCommerce to version 2.3 or newer%4$s to avoid " +"issues." +msgstr "" + +#: woocommerce-subscriptions.php:781 +#. translators: 1$-2$: opening and closing tags, 3$-4$: opening and +#. closing link tags. Leads to duplicate site article on docs +msgid "" +"It looks like this site has moved or is a duplicate site. %1$sWooCommerce " +"Subscriptions%2$s has disabled automatic payments and subscription related " +"emails on this site to prevent duplicate payments from a staging or test " +"environment. %3$sLearn more »%4$s." +msgstr "" + +#: woocommerce-subscriptions.php:783 +msgid "Quit nagging me (but don't enable automatic payments)" +msgstr "" + +#: woocommerce-subscriptions.php:784 +msgid "Enable automatic payments" +msgstr "" + +#: woocommerce-subscriptions.php:962 +msgid "Support" +msgstr "" + +#: woocommerce-subscriptions.php:1063 +#. translators: placeholders are opening and closing tags. Leads to docs on +#. version 2 +msgid "" +"Warning! Version 2.0 is a major update to the WooCommerce Subscriptions " +"extension. Before updating, please create a backup, update all WooCommerce " +"extensions and test all plugins, custom code and payment gateways with " +"version 2.0 on a staging site. %sLearn more about the changes in version " +"2.0 »%s" +msgstr "" + +#: woocommerce-subscriptions.php:1078 +msgid "" +"Warning! You are running version %s of WooCommerce Subscriptions plugin " +"code but your database has been upgraded to Subscriptions version 2.0. This " +"will cause major problems on your store." +msgstr "" + +#: woocommerce-subscriptions.php:1079 +msgid "" +"Please upgrade the WooCommerce Subscriptions plugin to version 2.0 or newer " +"immediately. If you need assistance, after upgrading to Subscriptions v2.0, " +"please %sopen a support ticket%s." +msgstr "" + +#. Plugin Name of the plugin/theme +msgid "WooCommerce Subscriptions" +msgstr "" + +#. Plugin URI of the plugin/theme +msgid "http://www.woothemes.com/products/woocommerce-subscriptions/" +msgstr "" + +#. Description of the plugin/theme +msgid "" +"Sell products and services with recurring payments in your WooCommerce " +"Store." +msgstr "" + +#. Author of the plugin/theme +msgid "Prospress Inc." +msgstr "" + +#. Author URI of the plugin/theme +msgid "http://prospress.com/" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:150 +#: includes/admin/class-wc-subscriptions-admin.php:193 +#: templates/admin/deprecated/html-variation-price.php:31 +#: templates/admin/deprecated/html-variation-price.php:86 +#: templates/admin/html-variation-price.php:21 +#: templates/admin/html-variation-price.php:48 +msgctxt "example price" +msgid "e.g. 9.90" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:173 +msgctxt "for in \"Every month _for_ 12 months\"" +msgid "for" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:217 +#: templates/admin/deprecated/html-variation-price.php:118 +#: templates/admin/html-variation-price.php:27 +#. translators: placeholder is trial period validation message if passed an +#. invalid value (e.g. "Trial period can not exceed 4 weeks") +msgctxt "Trial period dropdown's description in pricing fields" +msgid "" +"An optional period of time to wait before charging the first recurring " +"payment. Any sign up fee will still be charged at the outset of the " +"subscription. %s" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:716 +#. translators: placeholders are for HTML tags. They are 1$: "

    ", 2$: +#. "

    ", 3$: "

    ", 4$: "", 5$: "", 6$: "", 7$: "", 8$: +#. "

    " +msgctxt "used in admin pointer script params in javascript as type pointer content" +msgid "" +"%1$sChoose Subscription%2$s%3$sThe WooCommerce Subscriptions extension adds " +"two new subscription product types - %4$sSimple subscription%5$s and " +"%6$sVariable subscription%7$s.%8$s" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:718 +#. translators: placeholders are for HTML tags. They are 1$: "

    ", 2$: +#. "

    ", 3$: "

    ", 4$: "

    " +msgctxt "used in admin pointer script params in javascript as price pointer content" +msgid "" +"%1$sSet a Price%2$s%3$sSubscription prices are a little different to other " +"product prices. For a subscription, you can set a billing period, length, " +"sign-up fee and free trial.%4$s" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1048 +msgctxt "option section heading" +msgid "Renewals" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1080 +msgctxt "options section heading" +msgid "Miscellaneous" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1088 +msgctxt "there's a number immediately in front of this text" +msgid "suspensions per billing period." +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1382 +msgctxt "label that indicates whether debugging is turned on for the plugin" +msgid "WCS_DEBUG" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1388 +msgctxt "Live or Staging, Label on WooCommerce -> System Status page" +msgid "Subscriptions Mode" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1389 +msgctxt "refers to staging site" +msgid "Staging" +msgstr "" + +#: includes/admin/class-wc-subscriptions-admin.php:1389 +msgctxt "refers to live site" +msgid "Live" +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:54 +msgctxt "meta box title" +msgid "Subscription Data" +msgstr "" + +#: includes/admin/class-wcs-admin-meta-boxes.php:56 +msgctxt "meta box title" +msgid "Billing Schedule" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:151 +msgctxt "an action on a subscription" +msgid "Activate" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:152 +msgctxt "an action on a subscription" +msgid "Put on-hold" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:153 +#: includes/admin/class-wcs-admin-post-types.php:366 +#: includes/class-wc-subscriptions-manager.php:1762 +#: includes/wcs-user-functions.php:287 +#: templates/myaccount/related-orders.php:66 +msgctxt "an action on a subscription" +msgid "Cancel" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:228 +msgctxt "Used in order note. Reason why status changed." +msgid "Subscription status changed by bulk edit:" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:322 +msgctxt "number of orders linked to a subscription" +msgid "Orders" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:420 +msgctxt "meaning billing address" +msgid "Billing:" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:458 +#. translators: $1: is opening link, $2: is subscription order number, $3: is +#. closing link tag, $4: is user's name +msgctxt "Subscription title on admin table. (e.g.: #211 for John Doe)" +msgid "%1$s#%2$s%3$s for %4$s" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:831 +#. translators: placeholder is previous post title +msgctxt "used in post updated messages" +msgid "Subscription restored to revision from %s" +msgstr "" + +#: includes/admin/class-wcs-admin-post-types.php:836 +msgctxt "used in \"Subscription scheduled for \"" +msgid "M j, Y @ G:i" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-related-orders.php:82 +msgctxt "relation to order" +msgid "Resubscribed Subscription" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-related-orders.php:90 +msgctxt "relation to order" +msgid "Initial Subscription" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-related-orders.php:98 +msgctxt "relation to order" +msgid "Parent Order" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-related-orders.php:108 +msgctxt "relation to order" +msgid "Renewal Order" +msgstr "" + +#: includes/admin/meta-boxes/class-wcs-meta-box-subscription-data.php:48 +#. translators: placeholder is the ID of the subscription +msgctxt "edit subscription header" +msgid "Subscription #%s details" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-row.php:15 +#: includes/class-wc-subscriptions-renewal-order.php:136 +#: templates/myaccount/my-subscriptions.php:37 +#: templates/myaccount/related-orders.php:38 +#: templates/myaccount/related-subscriptions.php:32 +msgctxt "hash before order number" +msgid "#%s" +msgstr "" + +#: includes/class-wcs-query.php:87 +msgctxt "hash before order number" +msgid "Subscription #%s" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-row.php:29 +#. translators: php date format +msgctxt "post date" +msgid "Y/m/d g:i:s A" +msgstr "" + +#: includes/admin/meta-boxes/views/html-related-orders-table.php:21 +#: templates/myaccount/my-subscriptions.php:28 +#: templates/myaccount/related-orders.php:25 +#: templates/myaccount/related-subscriptions.php:23 +#: templates/myaccount/view-subscription.php:94 +msgctxt "table heading" +msgid "Total" +msgstr "" + +#: templates/emails/cancelled-subscription.php:28 wcs-functions.php:275 +msgctxt "table heading" +msgid "Last Payment" +msgstr "" + +#: templates/emails/subscription-info.php:19 +#: templates/myaccount/view-subscription.php:36 wcs-functions.php:272 +msgctxt "table heading" +msgid "Start Date" +msgstr "" + +#: templates/emails/subscription-info.php:20 +#: templates/myaccount/view-subscription.php:42 wcs-functions.php:276 +msgctxt "table heading" +msgid "End Date" +msgstr "" + +#: templates/emails/subscription-info.php:21 +msgctxt "table heading" +msgid "Price" +msgstr "" + +#: templates/myaccount/my-subscriptions.php:27 +#: templates/myaccount/my-subscriptions.php:43 +#: templates/myaccount/related-subscriptions.php:22 +#: templates/myaccount/related-subscriptions.php:38 wcs-functions.php:274 +msgctxt "table heading" +msgid "Next Payment" +msgstr "" + +#: wcs-functions.php:273 +msgctxt "table heading" +msgid "Trial End" +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:267 +#. translators: placeholder is error message +msgctxt "API error message when editing the order failed" +msgid "Edit subscription failed with error: %s" +msgstr "" + +#: includes/api/class-wc-api-subscriptions.php:605 +msgctxt "API response confirming order note deleted from a subscription" +msgid "Permanently deleted subscription note" +msgstr "" + +#: includes/class-wc-subscription.php:694 +msgctxt "original denotes there is no date to display" +msgid "-" +msgstr "" + +#: includes/class-wc-subscription.php:750 +#. translators: placeholder is date type (e.g. "end", "next_payment"...) +msgctxt "appears in an error message if date is wrong format" +msgid "Invalid %s date. The date must be of the format: \"Y-m-d H:i:s\"." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:259 +msgctxt "label on button, imperative" +msgid "Change Payment" +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:376 +msgctxt "%1$s: old payment title, %2$s: new payment title" +msgid "" +"Payment method changed from \"%1$s\" to \"%2$s\" by the subscriber from " +"their account page." +msgstr "" + +#: includes/class-wc-subscriptions-change-payment-gateway.php:501 +msgctxt "the page title of the change payment method form" +msgid "Change Payment Method" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:94 +#: includes/class-wc-subscriptions-manager.php:1826 +#: includes/class-wc-subscriptions-manager.php:1844 +msgctxt "used in order note as reason for why subscription status changed" +msgid "Subscription renewal payment due:" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:973 wcs-functions.php:205 +msgctxt "Subscription status" +msgid "Active" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:976 wcs-functions.php:207 +msgctxt "Subscription status" +msgid "Cancelled" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:979 wcs-functions.php:209 +msgctxt "Subscription status" +msgid "Expired" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:982 wcs-functions.php:204 +msgctxt "Subscription status" +msgid "Pending" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:985 +msgctxt "Subscription status" +msgid "Failed" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:989 +msgctxt "Subscription status" +msgid "On-hold" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:1958 wcs-functions.php:208 +msgctxt "Subscription status" +msgid "Switched" +msgstr "" + +#: wcs-functions.php:206 +msgctxt "Subscription status" +msgid "On hold" +msgstr "" + +#: wcs-functions.php:210 +msgctxt "Subscription status" +msgid "Pending Cancellation" +msgstr "" + +#: includes/class-wc-subscriptions-manager.php:1739 +#. translators: 1$: month number (e.g. "01"), 2$: month abbreviation (e.g. +#. "Jan") +msgctxt "used in a select box" +msgid "%1$s-%2$s" +msgstr "" + +#: includes/class-wc-subscriptions-order.php:648 +msgctxt "An order type" +msgid "Original" +msgstr "" + +#: includes/class-wc-subscriptions-order.php:651 +msgctxt "An order type" +msgid "Renewal" +msgstr "" + +#: includes/class-wc-subscriptions-renewal-order.php:241 +#. translators: 1$: blog name, 2$: order number +msgctxt "used in new renewal order email, deprecated" +msgid "[%1$s] New Subscription Renewal Order (%2$s)" +msgstr "" + +#: includes/class-wc-subscriptions-renewal-order.php:263 +#: includes/class-wc-subscriptions-renewal-order.php:287 +#. translators: placeholder is blog name +msgctxt "used as email subject for renewal order notification email to customer" +msgid "[%s] Subscription Renewal Order" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:319 +#: includes/class-wc-subscriptions-switcher.php:336 +#: includes/class-wc-subscriptions-switcher.php:370 +#: includes/class-wc-subscriptions-synchroniser.php:172 +msgctxt "when to allow a setting" +msgid "Never" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:320 +msgctxt "when to allow switching" +msgid "Between Subscription Variations" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:321 +msgctxt "when to allow switching" +msgid "Between Grouped Subscriptions" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:322 +msgctxt "when to allow switching" +msgid "Between Both Variations & Grouped Subscriptions" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:337 +msgctxt "when to prorate recurring fee when switching" +msgid "For Upgrades of Virtual Subscription Products Only" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:338 +msgctxt "when to prorate recurring fee when switching" +msgid "For Upgrades of All Subscription Products" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:339 +msgctxt "when to prorate recurring fee when switching" +msgid "For Upgrades & Downgrades of Virtual Subscription Products Only" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:340 +msgctxt "when to prorate recurring fee when switching" +msgid "For Upgrades & Downgrades of All Subscription Products" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:354 +msgctxt "when to prorate signup fee when switching" +msgid "Never (do not charge a sign up fee)" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:355 +msgctxt "when to prorate signup fee when switching" +msgid "Never (charge the full sign up fee)" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:356 +msgctxt "when to prorate signup fee when switching" +msgid "Always" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:371 +#: includes/class-wc-subscriptions-synchroniser.php:173 +msgctxt "when to prorate first payment / subscription length" +msgid "For Virtual Subscription Products Only" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:372 +#: includes/class-wc-subscriptions-synchroniser.php:174 +msgctxt "when to prorate first payment / subscription length" +msgid "For All Subscription Products" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:700 +#. translators: 1$: old item name, 2$: new item name when switching +msgctxt "used in order notes" +msgid "Customer switched from: %1$s to %2$s." +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:1594 +msgctxt "a switch order" +msgid "Downgrade" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:1597 +msgctxt "a switch order" +msgid "Upgrade" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:1600 +msgctxt "a switch order" +msgid "Crossgrade" +msgstr "" + +#: includes/class-wc-subscriptions-switcher.php:1605 +#. translators: %1: product subtotal, %2: HTML span tag, %3: direction +#. (upgrade, downgrade, crossgrade), %4: closing HTML span tag +msgctxt "product subtotal string" +msgid "%1$s %2$s(%3$s)%4$s" +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:50 +#. translators: placeholder is a year (e.g. "2016") +msgctxt "used in subscription product edit screen" +msgid "" +"Align the payment date for this subscription to a specific day of the year. " +"If the date has already taken place this year, the first payment will be " +"processed in %s. Set the day to 0 to disable payment syncing for this " +"product." +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:152 +#. translators: placeholders are opening and closing link tags +msgctxt "used in the general subscription options page" +msgid "" +"Align subscription renewal to a specific day of the week, month or year. " +"For example, the first day of the month. %sLearn more%s." +msgstr "" + +#: includes/class-wc-subscriptions-synchroniser.php:236 +#: templates/admin/deprecated/html-variation-synchronisation.php:36 +#: templates/admin/html-variation-synchronisation.php:32 +msgctxt "input field placeholder for day field for annual subscriptions" +msgid "Day" +msgstr "" + +#: includes/class-wcs-cart-renewal.php:563 +msgctxt "" +"Used in WooCommerce by removed item notification: \"_All linked " +"subscription items were_ removed. Undo?\" Filter for item title." +msgid "All linked subscription items were" +msgstr "" + +#: includes/class-wcs-remove-item.php:68 +msgctxt "hash before subscription ID" +msgid "Subscription #%d does not exist." +msgstr "" + +#: includes/class-wcs-remove-item.php:103 +#. translators: 1$: product name, 2$: product id +msgctxt "used in order note" +msgid "Customer added \"%1$s\" (Product ID: #%2$d) via the My Account page." +msgstr "" + +#: includes/class-wcs-remove-item.php:124 +#. translators: 1$: product name, 2$: product id +msgctxt "used in order note" +msgid "Customer removed \"%1$s\" (Product ID: #%2$d) via the My Account page." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:386 +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:393 +#. translators: placeholder is payment status (e.g. "completed") +msgctxt "used in order note" +msgid "IPN subscription payment %s." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:56 +msgctxt "order note left on subscription after user action" +msgid "Subscription reactivated by the subscriber from their account page." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:66 +msgctxt "order note left on subscription after user action" +msgid "Subscription put on hold by the subscriber from their account page." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:75 +msgctxt "order note left on subscription after user action" +msgid "Subscription cancelled by the subscriber from their account page." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:57 +msgctxt "Notice displayed to user confirming their action." +msgid "Your subscription has been reactivated." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:67 +msgctxt "Notice displayed to user confirming their action." +msgid "Your subscription has been put on hold." +msgstr "" + +#: includes/class-wcs-user-change-status-handler.php:76 +msgctxt "Notice displayed to user confirming their action." +msgid "Your subscription has been cancelled." +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:32 +#. translators: placeholder is {blogname}, a variable that will be substituted +#. when email is sent out +msgctxt "default email subject for cancelled emails sent to the admin" +msgid "[%s] Subscription Cancelled" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:120 +#: includes/emails/class-wcs-email-customer-renewal-invoice.php:166 +msgctxt "an email notification" +msgid "Enable/Disable" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:126 +msgctxt "of an email" +msgid "Recipient(s)" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:134 +msgctxt "of an email" +msgid "Subject" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:141 +msgctxt "" +"Name the setting that controls the main heading contained within the email " +"notification" +msgid "Email Heading" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:148 +msgctxt "text, html or multipart" +msgid "Email type" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:154 +msgctxt "email type" +msgid "Plain text" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:155 +msgctxt "email type" +msgid "HTML" +msgstr "" + +#: includes/emails/class-wcs-email-cancelled-subscription.php:156 +msgctxt "email type" +msgid "Multipart" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-renewal-order.php:29 +msgctxt "Default email heading for email to customer on completed renewal order" +msgid "Your renewal order is complete" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-renewal-order.php:31 +#. translators: $1: {blogname}, $2: {order_date}, variables that will be +#. substituted when email is sent out +msgctxt "Default email subject for email to customer on completed renewal order" +msgid "Your %1$s renewal order from %2$s is complete" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-renewal-order.php:38 +msgctxt "Default email heading for email with downloadable files in it" +msgid "Your subscription renewal order is complete - download your files" +msgstr "" + +#: includes/emails/class-wcs-email-customer-completed-renewal-order.php:40 +#. translators: $1: {blogname}, $2: {order_date}, variables will be substituted +#. when email is sent out +msgctxt "Default email subject for email with downloadable files in it" +msgid "" +"Your %1$s subscription renewal order from %2$s is complete - download your " +"files" +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:335 +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:208 +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:316 +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:327 +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:355 +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:367 +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-request.php:137 +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-request.php:140 +#. translators: placeholder is for blog name +msgctxt "" +"hash before the order number. Used as a character to remove from the actual " +"order number" +msgid "#" +msgstr "" + +#: includes/gateways/paypal/class-wcs-paypal.php:547 +msgctxt "" +"used in User Agent data sent to PayPal to help identify where a payment " +"came from" +msgid "WooCommerce Subscriptions PayPal" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:71 +#. translators: placeholder is blogname +msgctxt "data sent to paypal" +msgid "Orders with %s" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-request.php:398 +#. translators: 1$: new status (e.g. "Cancel"), 2$: blog name +msgctxt "data sent to paypal" +msgid "%1$s subscription event triggered at %2$s" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response.php:105 +msgctxt "used in api error message if there is no severity code from PayPal" +msgid "Error" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response.php:107 +msgctxt "used in api error message if there is no long message" +msgid "Unknown error" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-reference-transaction-api-response.php:119 +#: templates/admin/post-types/writepanels/order-shipping-html.php:14 +#: templates/admin/post-types/writepanels/order-tax-html.php:9 +#: templates/myaccount/view-subscription.php:274 +#: templates/myaccount/view-subscription.php:293 +msgctxt "no information about something" +msgid "N/A" +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-ipn-handler.php:255 +msgctxt "" +"when it is a payment change, and there is a subscr_signup message, this " +"will be a confirmation message that PayPal accepted it being the new " +"payment method" +msgid "IPN subscription payment method changed to PayPal." +msgstr "" + +#: includes/gateways/paypal/includes/class-wcs-paypal-standard-request.php:74 +#. translators: 1$: subscription ID, 2$: order ID, 3$: names of items, comma +#. separated +msgctxt "item name sent to paypal" +msgid "Subscription %1$s (Order %2$s) - %3$s" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:258 +#. translators: placeholder is number of upgraded subscriptions +msgctxt "used in the subscriptions upgrader" +msgid "Marked %s subscription products as \"sold individually\"." +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:289 +#: includes/upgrades/class-wc-subscriptions-upgrader.php:339 +#. translators: placeholder is "{time_left}", will be replaced on front end +#. with actual time +msgctxt "Message that gets sent to front end." +msgid "Estimated time left (minutes:seconds): %s" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:318 +#. translators: placeholder is the number of subscriptions repaired +msgctxt "Repair message that gets sent to front end." +msgid "" +"Repaired %d subscriptions with incorrect dates, line tax data or missing " +"customer notes." +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:324 +#. translators: placeholder is number of subscriptions that were checked and +#. did not need repairs. There's a space at the beginning! +msgctxt "Repair message that gets sent to front end." +msgid " %d other subscription was checked and did not need any repairs." +msgid_plural "%d other subscriptions were checked and did not need any repairs." +msgstr[0] "" +msgstr[1] "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:328 +#. translators: placeholder is "{execution_time}", which will be replaced on +#. front end with actual time +msgctxt "Repair message that gets sent to front end." +msgid "(in %s seconds)" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:331 +#. translators: $1: "Repaired x subs with incorrect dates...", $2: "X others +#. were checked and no repair needed", $3: "(in X seconds)". Ordering for RTL +#. languages. +msgctxt "The assembled repair message that gets sent to front end." +msgid "%1$s%2$s %3$s" +msgstr "" + +#: includes/upgrades/class-wc-subscriptions-upgrader.php:350 +#. translators: 1$: error message, 2$: opening link tag, 3$: closing link tag +msgctxt "Error message that gets sent to front end when upgrading Subscriptions" +msgid "" +"Unable to repair subscriptions.
    Error: %1$s
    Please refresh the page " +"and try again. If problem persists, %2$scontact support%3$s." +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:36 +#: woocommerce-subscriptions.php:961 +msgctxt "short for documents" +msgid "Docs" +msgstr "" + +#: includes/upgrades/templates/wcs-about.php:121 +msgctxt "h3 on the About Subscriptions page for this new feature" +msgid "Change Payment Method" +msgstr "" + +#: includes/upgrades/templates/wcs-upgrade.php:45 +msgctxt "text on submit button" +msgid "Update Database" +msgstr "" + +#: includes/wcs-cart-functions.php:179 +msgctxt "shipping method price" +msgid "Free" +msgstr "" + +#: includes/wcs-cart-functions.php:254 +#: templates/myaccount/view-subscription.php:199 +#: templates/myaccount/view-subscription.php:204 +#. translators: placeholder is price string, denotes tax included in cart/order +#. total +msgctxt "includes tax" +msgid "(Includes %s)" +msgstr "" + +#: includes/wcs-formatting-functions.php:39 +msgctxt "initial payment on a subscription" +msgid "up front" +msgstr "" + +#: includes/wcs-order-functions.php:135 +msgctxt "" +"In wcs_copy_order_meta error message. Refers to origin and target order " +"objects." +msgid "Invalid data. Orders expected aren't orders." +msgstr "" + +#: includes/wcs-order-functions.php:139 +msgctxt "" +"Refers to the type of the copy being performed: \"copy_order\", " +"\"subscription\", \"renewal_order\", \"resubscribe_order\"" +msgid "Invalid data. Type of copy is not a string." +msgstr "" + +#: includes/wcs-order-functions.php:263 wcs-functions.php:151 +#. translators: Order date parsed by strftime +msgctxt "Used in subscription post title. \"Subscription renewal order - \"" +msgid "%b %d, %Y @ %I:%M %p" +msgstr "" + +#: includes/wcs-time-functions.php:30 +#. translators: placeholder is number of days. (e.g. "Bill this every day / 4 +#. days") +msgctxt "Subscription billing period." +msgid "day" +msgid_plural "%s days" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:32 +#. translators: placeholder is number of weeks. (e.g. "Bill this every week / 4 +#. weeks") +msgctxt "Subscription billing period." +msgid "week" +msgid_plural "%s weeks" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:34 +#. translators: placeholder is number of months. (e.g. "Bill this every month / +#. 4 months") +msgctxt "Subscription billing period." +msgid "month" +msgid_plural "%s months" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:36 +#. translators: placeholder is number of years. (e.g. "Bill this every year / 4 +#. years") +msgctxt "Subscription billing period." +msgid "year" +msgid_plural "%s years" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:81 +msgctxt "Subscription length (eg \"$10 per month for _all time_\")" +msgid "all time" +msgstr "" + +#: includes/wcs-time-functions.php:86 +msgctxt "Subscription lengths. e.g. \"For 1 day...\"" +msgid "1 day" +msgstr "" + +#: includes/wcs-time-functions.php:90 +msgctxt "Subscription lengths. e.g. \"For 1 week...\"" +msgid "1 week" +msgstr "" + +#: includes/wcs-time-functions.php:94 +msgctxt "Subscription lengths. e.g. \"For 1 month...\"" +msgid "1 month" +msgstr "" + +#: includes/wcs-time-functions.php:98 +msgctxt "Subscription lengths. e.g. \"For 1 year...\"" +msgid "1 year" +msgstr "" + +#: includes/wcs-time-functions.php:148 +msgctxt "period interval (eg \"$10 _every_ 2 weeks\")" +msgid "every" +msgstr "" + +#: includes/wcs-time-functions.php:152 +#. translators: period interval, placeholder is ordinal (eg "$10 every +#. _2nd/3rd/4th_", etc) +msgctxt "period interval with ordinal number (e.g. \"every 2nd\"" +msgid "every %s" +msgstr "" + +#: includes/wcs-time-functions.php:176 +msgctxt "" +"Used in the trial period dropdown. Number is in text field. 0, 2+ will need " +"plural, 1 will need singular." +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:177 +msgctxt "" +"Used in the trial period dropdown. Number is in text field. 0, 2+ will need " +"plural, 1 will need singular." +msgid "week" +msgid_plural "weeks" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:178 +msgctxt "" +"Used in the trial period dropdown. Number is in text field. 0, 2+ will need " +"plural, 1 will need singular." +msgid "month" +msgid_plural "months" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:179 +msgctxt "" +"Used in the trial period dropdown. Number is in text field. 0, 2+ will need " +"plural, 1 will need singular." +msgid "year" +msgid_plural "years" +msgstr[0] "" +msgstr[1] "" + +#: includes/wcs-time-functions.php:197 +msgctxt "no trial period" +msgid "no" +msgstr "" + +#: templates/admin/deprecated/html-variation-price.php:59 +msgctxt "" +"Edit product screen, between the Billing Period and Subscription Length " +"dropdowns" +msgid "for" +msgstr "" + +#: templates/admin/deprecated/html-variation-price.php:105 +msgctxt "example number of days / weeks / months" +msgid "e.g. 3" +msgstr "" + +#: templates/checkout/form-change-payment-method.php:20 +#: templates/emails/admin-new-renewal-order.php:35 +#: templates/emails/admin-new-switch-order.php:38 +#: templates/emails/admin-new-switch-order.php:76 +#: templates/emails/cancelled-subscription.php:54 +#: templates/emails/customer-completed-renewal-order.php:36 +#: templates/emails/customer-completed-switch-order.php:36 +#: templates/emails/customer-completed-switch-order.php:75 +#: templates/emails/customer-processing-renewal-order.php:31 +#: templates/emails/customer-renewal-invoice.php:42 +#: templates/myaccount/view-subscription.php:93 +msgctxt "table headings in notification email" +msgid "Product" +msgstr "" + +#: templates/checkout/form-change-payment-method.php:21 +#: templates/emails/admin-new-renewal-order.php:36 +#: templates/emails/admin-new-switch-order.php:39 +#: templates/emails/admin-new-switch-order.php:77 +#: templates/emails/cancelled-subscription.php:55 +#: templates/emails/customer-completed-renewal-order.php:37 +#: templates/emails/customer-completed-switch-order.php:37 +#: templates/emails/customer-completed-switch-order.php:76 +#: templates/emails/customer-processing-renewal-order.php:32 +#: templates/emails/customer-renewal-invoice.php:43 +msgctxt "table headings in notification email" +msgid "Quantity" +msgstr "" + +#: templates/checkout/form-change-payment-method.php:22 +msgctxt "table headings in notification email" +msgid "Totals" +msgstr "" + +#: templates/emails/admin-new-renewal-order.php:37 +#: templates/emails/admin-new-switch-order.php:40 +#: templates/emails/admin-new-switch-order.php:78 +#: templates/emails/cancelled-subscription.php:27 +#: templates/emails/cancelled-subscription.php:56 +#: templates/emails/customer-completed-renewal-order.php:38 +#: templates/emails/customer-completed-switch-order.php:38 +#: templates/emails/customer-completed-switch-order.php:77 +#: templates/emails/customer-processing-renewal-order.php:33 +#: templates/emails/customer-renewal-invoice.php:44 +msgctxt "table headings in notification email" +msgid "Price" +msgstr "" + +#: templates/emails/cancelled-subscription.php:29 +msgctxt "table headings in notification email" +msgid "End of Prepaid Term" +msgstr "" + +#: templates/checkout/form-change-payment-method.php:55 +msgctxt "text on button on checkout page" +msgid "Change Payment Method" +msgstr "" + +#: templates/emails/admin-new-renewal-order.php:19 +#: templates/emails/plain/admin-new-renewal-order.php:16 +#. translators: $1: customer's billing first name, $2: customer's billing last +#. name +msgctxt "Used in admin email: new renewal order" +msgid "" +"You have received a subscription renewal order from %1$s %2$s. Their order " +"is as follows:" +msgstr "" + +#: templates/emails/admin-new-renewal-order.php:28 +#: templates/emails/customer-completed-renewal-order.php:29 +#: templates/emails/customer-processing-renewal-order.php:24 +#. translators: $1: order's order number, $2: date of order in
    +
    +

    + + + + + + + +

    +

    + + +

    +
    diff --git a/templates/admin/html-variation-synchronisation.php b/templates/admin/html-variation-synchronisation.php new file mode 100644 index 0000000..eecbbab --- /dev/null +++ b/templates/admin/html-variation-synchronisation.php @@ -0,0 +1,40 @@ + +
    +
    +
    + + +
    +
    + + + +
    +
    +
    diff --git a/templates/admin/post-types/writepanels/order-shipping-html.php b/templates/admin/post-types/writepanels/order-shipping-html.php new file mode 100644 index 0000000..6baacf9 --- /dev/null +++ b/templates/admin/post-types/writepanels/order-shipping-html.php @@ -0,0 +1,48 @@ + +
    +

    + + +

    +

    + +

    +

    + +

    + + × +
    +
    diff --git a/templates/admin/post-types/writepanels/order-tax-html.php b/templates/admin/post-types/writepanels/order-tax-html.php new file mode 100644 index 0000000..a48b174 --- /dev/null +++ b/templates/admin/post-types/writepanels/order-tax-html.php @@ -0,0 +1,26 @@ + +
    +

    + + +

    +

    + + +

    +

    + + +

    + × +
    +
    diff --git a/templates/cart/cart-recurring-shipping.php b/templates/cart/cart-recurring-shipping.php new file mode 100644 index 0000000..cdeb304 --- /dev/null +++ b/templates/cart/cart-recurring-shipping.php @@ -0,0 +1,42 @@ + + + + + + + +
      + +
    • + %3$s', esc_attr( $index ), esc_attr( sanitize_title( $method->id ) ), wp_kses_post( wcs_cart_totals_shipping_method( $method, $recurring_cart ) ) ); + do_action( 'woocommerce_after_shipping_rate', $method, $index ); + ?> +
    • + +
    + customer->has_calculated_shipping() ) : ?> + + + + + + + ' . esc_html( $package_details ) . '

    '; ?> + + + diff --git a/templates/checkout/form-change-payment-method.php b/templates/checkout/form-change-payment-method.php new file mode 100644 index 0000000..140a973 --- /dev/null +++ b/templates/checkout/form-change-payment-method.php @@ -0,0 +1,96 @@ + +
    + + + + + + + + + + + get_order_item_totals() ) { + foreach ( $totals as $total ) : ?> + + + + + + + + get_items(); + if ( sizeof( $recurring_order_items ) > 0 ) : + foreach ( $recurring_order_items as $item ) : + echo ' + + + + + '; + endforeach; + endif; + ?> + +
    ' . esc_html( $item['name'] ) . '' . esc_html( $item['qty'] ) . '' . wp_kses_post( $subscription->get_formatted_line_subtotal( $item ) ) . '
    + +
    + payment_gateways->get_available_payment_gateways() ) { ?> +
      + set_current(); + } + + foreach ( $available_gateways as $gateway ) { ?> +
    • + chosen, true ); ?> data-order_button_text="" /> + + has_fields() || $gateway->get_description() ) { + echo ''; + } + ?> +
    • + +
    + +
    +

    +
    + + + +
    + + ' ), array( 'input' => array( 'type' => array(), 'class' => array(), 'id' => array(), 'value' => array(), 'data-value' => array() ) ) ); ?> + +
    + + +
    + +
    diff --git a/templates/checkout/recurring-totals.php b/templates/checkout/recurring-totals.php new file mode 100644 index 0000000..30d0e02 --- /dev/null +++ b/templates/checkout/recurring-totals.php @@ -0,0 +1,109 @@ + + + + + + + $recurring_cart ) : ?> + next_payment_date ) : ?> + + + + + + + + + + + + cart->get_coupons() as $code => $coupon ) : ?> + $recurring_cart ) : ?> + next_payment_date ) : ?> + + + get_coupons() as $recurring_code => $recurring_coupon ) : ?> + + + + + + + + + + + + + cart->needs_shipping() && WC()->cart->show_shipping() ) : ?> + + + + cart->tax_display_cart === 'excl' ) : ?> + + + cart->get_tax_totals() as $code => $tax ) : ?> + $recurring_cart ) : ?> + next_payment_date ) : ?> + + + get_tax_totals() as $recurring_code => $recurring_tax ) : ?> + + + + label ); ?> + + + + formatted_amount, $recurring_cart ) ); ?> + + + + + + + + + $recurring_cart ) : ?> + next_payment_date ) : ?> + + + + + countries->tax_or_vat() ); ?> + + + + get_taxes_total(), $recurring_cart ) ); ?> + + + + + + + $recurring_cart ) : ?> + next_payment_date ) : ?> + + + + + + + + + diff --git a/templates/emails/admin-new-renewal-order.php b/templates/emails/admin-new-renewal-order.php new file mode 100644 index 0000000..55a59f2 --- /dev/null +++ b/templates/emails/admin-new-renewal-order.php @@ -0,0 +1,86 @@ + + + + +

    + billing_first_name ), esc_html( $order->billing_last_name ) ); + ?> +

    + + + +

    + element + printf( esc_html_x( 'Order: %1$s (%2$s)', 'Used in email notification', 'woocommerce-subscriptions' ), esc_html( $order->get_order_number() ), sprintf( '', esc_attr( date_i18n( 'c', strtotime( $order->order_date ) ) ), esc_html( date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) ) ); + ?> +

    + + + + + + + + + + + false, 'show_sku' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + +

    + +billing_email ) : ?> +

    + tag, $2: closing tag, $3: billing email + printf( esc_html__( '%1$sEmail:%2$s %3$s', 'woocommerce-subscriptions' ), '', '', esc_html( $order->billing_email ) ); + ?> +

    + +billing_phone ) : ?> +

    + tag, $2: closing tag, $3: billing phone + printf( esc_html__( '%1$sTel:%2$s %3$s', 'woocommerce-subscriptions' ), '', '', esc_html( $order->billing_phone ) ); + ?> +

    + + + $order ) ); ?> + + diff --git a/templates/emails/admin-new-switch-order.php b/templates/emails/admin-new-switch-order.php new file mode 100644 index 0000000..4b5024e --- /dev/null +++ b/templates/emails/admin-new-switch-order.php @@ -0,0 +1,106 @@ + + + + +

    + billing_first_name, $order->billing_last_name, $count ) ); + ?> +

    + +

    + +

    + id ) ) . '">' . esc_html( $order->get_order_number() ) .'' ) ); + ?> +

    + + + + + + + + + + + + + false, 'show_sku' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + +

    + + + +

    id ) ) . '">' . esc_html( $subscription->get_order_number() ) .'' ); ?>

    + + + + + + + + + + + false, 'show_sku' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + + diff --git a/templates/emails/cancelled-subscription.php b/templates/emails/cancelled-subscription.php new file mode 100644 index 0000000..25e1586 --- /dev/null +++ b/templates/emails/cancelled-subscription.php @@ -0,0 +1,83 @@ + + + + +

    + billing_first_name ), esc_html( $subscription->billing_last_name ) ); + ?> +

    + + + + + + + + + + + + + + + + + + +
    + get_order_number() ); ?> + + get_formatted_order_total() ); ?> + + get_date_to_display( 'last_payment' ) ); ?> + + get_time( 'end', 'site' ) ) ); ?> +
    +
    + + + + + + + + + + + false, 'show_sku' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + diff --git a/templates/emails/customer-completed-renewal-order.php b/templates/emails/customer-completed-renewal-order.php new file mode 100644 index 0000000..50204f8 --- /dev/null +++ b/templates/emails/customer-completed-renewal-order.php @@ -0,0 +1,68 @@ + + + + +

    + +

    + + + +

    + element + printf( esc_html_x( 'Order: %1$s (%2$s)', 'Used in email notification', 'woocommerce-subscriptions' ), esc_html( $order->get_order_number() ), sprintf( '', esc_attr( date_i18n( 'c', strtotime( $order->order_date ) ) ), esc_html( date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) ) ); + ?> +

    + + + + + + + + + + + true, 'show_sku' => false, 'show_purchase_note' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + + + + diff --git a/templates/emails/customer-completed-switch-order.php b/templates/emails/customer-completed-switch-order.php new file mode 100644 index 0000000..ba5aeea --- /dev/null +++ b/templates/emails/customer-completed-switch-order.php @@ -0,0 +1,105 @@ + + + + +

    + +

    + + + +

    +

    + get_view_order_url() ) . '">' . esc_html( $order->get_order_number() ) . '' ) ); + ?> +

    + + + + + + + + + + true, 'show_sku' => false, 'show_purchase_note' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + +

    + + + +

    get_view_order_url() ) . '">' . esc_html( $subscription->get_order_number() ) . '' ); ?>

    + + + + + + + + + + + true, 'show_sku' => false, 'show_purchase_note' => true ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + + diff --git a/templates/emails/customer-processing-renewal-order.php b/templates/emails/customer-processing-renewal-order.php new file mode 100644 index 0000000..1fbc62e --- /dev/null +++ b/templates/emails/customer-processing-renewal-order.php @@ -0,0 +1,86 @@ + + + + +

    + + + +

    + element + printf( esc_html_x( 'Order: %1$s (%2$s)', 'Used in email notification', 'woocommerce-subscriptions' ), esc_html( $order->get_order_number() ), sprintf( '', esc_attr( date_i18n( 'c', strtotime( $order->order_date ) ) ), esc_html( date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) ) ); + ?> +

    + + + + + + + + + + + $order->is_download_permitted(), + 'show_sku' => true, + 'show_purchase_note' => ( 'processing' == $order->status ) ? true : false, + ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + + +

    + +billing_email ) : ?> +

    + tag, $2: closing tag, $3: billing email + printf( esc_html__( '%1$sEmail:%2$s %3$s', 'woocommerce-subscriptions' ), '', '', esc_html( $order->billing_email ) ); + ?> +

    + +billing_phone ) : ?> +

    + tag, $2: closing tag, $3: billing phone + printf( esc_html__( '%1$sTel:%2$s %3$s', 'woocommerce-subscriptions' ), '', '', esc_html( $order->billing_phone ) ); + ?> +

    + + + $order ) ); ?> + + diff --git a/templates/emails/customer-renewal-invoice.php b/templates/emails/customer-renewal-invoice.php new file mode 100644 index 0000000..71daafc --- /dev/null +++ b/templates/emails/customer-renewal-invoice.php @@ -0,0 +1,70 @@ + + + + +status == 'pending' ) : ?> +

    + get_checkout_payment_url() ) . '">' . esc_html__( 'Pay Now »', 'woocommerce-subscriptions' ) . '' ), array( 'a' => array( 'href' => true ) ) ); + ?> +

    +status ) : ?> +

    + get_checkout_payment_url() ) . '">' . esc_html__( 'Pay Now »', 'woocommerce-subscriptions' ) . '' ), array( 'a' => array( 'href' => true ) ) ); ?>

    + + + + +

    + get_order_number() ) ); + ?> +

    + + + + + + + + + + + false, 'show_sku' => true, 'show_purchase_note' => false ) ) ); ?> + + + get_order_item_totals() ) { + $i = 0; + foreach ( $totals as $total ) { + $i++; + ?> + + + + + + +
    + + + + diff --git a/templates/emails/plain/admin-new-renewal-order.php b/templates/emails/plain/admin-new-renewal-order.php new file mode 100644 index 0000000..7450b74 --- /dev/null +++ b/templates/emails/plain/admin-new-renewal-order.php @@ -0,0 +1,66 @@ +billing_first_name , $order->billing_last_name ); + +echo "\n\n"; + +echo "****************************************************\n\n"; + +do_action( 'woocommerce_email_before_order_table', $order, true, true ); + +printf( __( 'Order number: %s', 'woocommerce-subscriptions' ), $order->get_order_number() ) . "\n"; +printf( __( 'Order date: %s', 'woocommerce-subscriptions' ), date_i18n( _x( 'jS F Y', 'date format for order date in notification emails', 'woocommerce-subscriptions' ), strtotime( $order->order_date ) ) ) . "\n"; + +do_action( 'woocommerce_email_order_meta', $order, true, true ); + +echo "\n" . WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => false, + 'show_sku' => true, + 'show_purchase_note' => '', + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, +) ); + +echo "----------\n\n"; + +if ( $totals = $order->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} + +echo "\n****************************************************\n\n"; + +do_action( 'woocommerce_email_after_order_table', $order, true, true ); + +echo __( 'Customer details', 'woocommerce-subscriptions' ) . "\n"; + +if ( $order->billing_email ) { + // translators: placeholder is customer's billing email + echo sprintf( __( 'Email: %s', 'woocommerce-subscriptions' ), $order->billing_email ) . "\n"; +} + +if ( $order->billing_phone ) { + // translators: placeholder is customer's billing phone number + echo sprintf( __( 'Tel: %s', 'woocommerce-subscriptions' ), $order->billing_phone ) . "\n"; +} + +wc_get_template( 'emails/plain/email-addresses.php', array( 'order' => $order ) ); + +echo "\n****************************************************\n\n"; + +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/admin-new-switch-order.php b/templates/emails/plain/admin-new-switch-order.php new file mode 100644 index 0000000..cac9365 --- /dev/null +++ b/templates/emails/plain/admin-new-switch-order.php @@ -0,0 +1,85 @@ +billing_first_name, $order->billing_last_name, $count ); + +echo "\n\n****************************************************\n\n"; + +do_action( 'woocommerce_email_before_order_table', $order, true, true ); + +echo strtoupper( sprintf( __( 'Order number: %s', 'woocommerce-subscriptions' ), $order->get_order_number() ) ) . "\n"; +echo date_i18n( _x( 'jS F Y', 'date format for order date in notification emails', 'woocommerce-subscriptions' ), strtotime( $order->order_date ) ) . "\n"; + +do_action( 'woocommerce_email_order_meta', $order, true, true ); + +echo "\n" . WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => false, + 'show_sku' => true, + 'show_purchase_note' => '', + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, +) ); + +echo "***********\n\n"; + +if ( $totals = $order->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} + +// translators: placeholder is edit post link for the order +echo "\n" . sprintf( __( 'View order: %s', 'woocommerce-subscriptions' ), wcs_get_edit_post_link( $order->id ) ) . "\n"; +echo "\n****************************************************\n\n"; + +do_action( 'woocommerce_email_after_order_table', $order, true, true ); +remove_filter( 'woocommerce_order_item_meta_end', 'WC_Subscriptions_Switcher::print_switch_link', 10 ); + +foreach ( $subscriptions as $subscription ) { + + do_action( 'woocommerce_email_before_subscription_table', $subscription , true, true ); + + echo strtoupper( sprintf( __( 'Subscription Number: %s', 'woocommerce-subscriptions' ), $subscription->get_order_number() ) ) . "\n"; + + echo "\n" . WC_Subscriptions_Email::email_order_items_table( $subscription, array( + 'show_download_links' => false, + 'show_sku' => true, + 'show_purchase_note' => '', + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, + ) ); + echo "***********\n"; + + if ( $totals = $subscription->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } + } + // translators: placeholder is edit post link for the subscription + echo "\n" . sprintf( _x( 'View Subscription: %s', 'in plain emails for subscription information', 'woocommerce-subscriptions' ), wcs_get_edit_post_link( $subscription->id ) ) . "\n"; + do_action( 'woocommerce_email_after_subscription_table', $subscription , true, true ); +} + +add_filter( 'woocommerce_order_item_meta_end', 'WC_Subscriptions_Switcher::print_switch_link', 10 ); +echo "\n***************************************************\n\n"; + +do_action( 'woocommerce_email_customer_details', $order, true, true ); + +echo "\n****************************************************\n\n"; +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/cancelled-subscription.php b/templates/emails/plain/cancelled-subscription.php new file mode 100644 index 0000000..fb31a0c --- /dev/null +++ b/templates/emails/plain/cancelled-subscription.php @@ -0,0 +1,61 @@ +billing_first_name, $subscription->billing_last_name ); + +echo "\n\n****************************************************\n"; + +echo strtoupper( sprintf( __( 'Subscription Number: %s', 'woocommerce-subscriptions' ), $subscription->get_order_number() ) ) . "\n"; +// translators: placeholder is last time subscription was paid +printf( __( 'Last Payment: %s', 'woocommerce-subscriptions' ), $subscription->get_date_to_display( 'last_payment' ) ) . "\n"; + +$end_time = $subscription->get_time( 'end', 'site' ); + +if ( ! empty( $end_time ) ) { + // translators: placeholder is localised date string + printf( __( 'End of Prepaid Term: %s', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), $end_time ) ) . "\n"; +} + +do_action( 'woocommerce_email_order_meta', $subscription, true, true ); + +echo "\n\n****************************************************\n\n"; + +do_action( 'woocommerce_email_before_subscription_table', $subscription, true, true ); +echo WC_Subscriptions_Email::email_order_items_table( $subscription, array( + 'show_download_links' => false, + 'show_sku' => true, + 'show_purchase_note' => '', + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, +) ); +echo "***********\n\n"; + +if ( $totals = $subscription->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} +// translators: placeholder is edit post link for the subscription +echo "\n" . sprintf( _x( 'View Subscription: %s', 'in plain emails for subscription information', 'woocommerce-subscriptions' ), wcs_get_edit_post_link( $subscription->id ) ) . "\n"; +do_action( 'woocommerce_email_after_subscription_table', $subscription, true, true ); + +echo "\n***************************************************\n\n"; + +do_action( 'woocommerce_email_customer_details', $subscription, true, true ); + +echo "\n****************************************************\n\n"; + +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/customer-completed-renewal-order.php b/templates/emails/plain/customer-completed-renewal-order.php new file mode 100644 index 0000000..3001709 --- /dev/null +++ b/templates/emails/plain/customer-completed-renewal-order.php @@ -0,0 +1,66 @@ +get_order_number() ) . "\n"; +printf( __( 'Order date: %s', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) . "\n"; + +do_action( 'woocommerce_email_order_meta', $order, false, true ); + +echo "\n" . WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => true, + 'show_sku' => false, + 'show_purchase_note' => true, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, +) ); + +echo "----------\n\n"; + +if ( $totals = $order->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} + +echo "\n****************************************************\n\n"; + +do_action( 'woocommerce_email_after_order_table', $order, false, true ); + +echo __( 'Your details', 'woocommerce-subscriptions' ) . "\n\n"; + +if ( $order->billing_email ) { + // translators: placeholder is customer's billing email + printf( __( 'Email: %s', 'woocommerce-subscriptions' ), $order->billing_email ); + echo "\n"; +} + +if ( $order->billing_phone ) { + // translators: placeholder is customer's billing phone number + printf( __( 'Tel: %s', 'woocommerce-subscriptions' ), $order->billing_phone ); + echo "\n"; +} + +wc_get_template( 'emails/plain/email-addresses.php', array( 'order' => $order ) ); + +echo "\n****************************************************\n\n"; + +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/customer-completed-switch-order.php b/templates/emails/plain/customer-completed-switch-order.php new file mode 100644 index 0000000..fe2c622 --- /dev/null +++ b/templates/emails/plain/customer-completed-switch-order.php @@ -0,0 +1,78 @@ +get_order_number() ) ) . "\n"; +printf( __( 'Order date: %s', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) . "\n"; + +do_action( 'woocommerce_email_order_meta', $order, false, true ); + +echo "\n" . WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => true, + 'show_sku' => false, + 'show_purchase_note' => true, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, +) ); + +echo "***********\n\n"; + +if ( $totals = $order->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} + +// translators: placeholder is order's view url +echo "\n" . sprintf( __( 'View your order: %s', 'woocommerce-subscriptions' ), $order->get_view_order_url() ) . "\n"; +echo "\n****************************************************\n\n"; + +foreach ( $subscriptions as $subscription ) { + + do_action( 'woocommerce_email_before_subscription_table', $subscription, false, true ); + + echo strtoupper( sprintf( __( 'Subscription Number: %s', 'woocommerce-subscriptions' ), $subscription->get_order_number() ) ) . "\n"; + + echo "\n" . WC_Subscriptions_Email::email_order_items_table( $subscription, array( + 'show_download_links' => true, + 'show_sku' => false, + 'show_purchase_note' => true, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, + ) ); + echo "***********\n"; + + if ( $totals = $subscription->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } + } + // translators: placeholder is subscription's view url + echo "\n" . sprintf( __( 'View your subscription: %s', 'woocommerce-subscriptions' ), $subscription->get_view_order_url() ) . "\n"; + do_action( 'woocommerce_email_after_subscription_table', $subscription, false, true ); +} +echo "\n***************************************************\n\n"; + +do_action( 'woocommerce_email_customer_details', $order, true, true ); + +echo "\n****************************************************\n\n"; +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/customer-processing-renewal-order.php b/templates/emails/plain/customer-processing-renewal-order.php new file mode 100644 index 0000000..16d95a7 --- /dev/null +++ b/templates/emails/plain/customer-processing-renewal-order.php @@ -0,0 +1,65 @@ +get_order_number() ) . "\n"; +printf( __( 'Order date: %s', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) . "\n"; + +do_action( 'woocommerce_email_order_meta', $order, false, true ); + +echo "\n" . WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => $order->is_download_permitted(), + 'show_sku' => true, + 'show_purchase_note' => ( 'processing' == $order->status ) ? true : false, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, + ) ); + +echo "----------\n\n"; + +if ( $totals = $order->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} + +echo "\n****************************************************\n\n"; + +do_action( 'woocommerce_email_after_order_table', $order, false, true ); + +echo __( 'Your details', 'woocommerce-subscriptions' ) . "\n\n"; + +if ( $order->billing_email ) { + // translators: placeholder is customer's billing email + printf( __( 'Email: %s', 'woocommerce-subscriptions' ), $order->billing_email ); + echo "\n"; +} + +if ( $order->billing_phone ) { + // translators: placeholder is customer's billing phone number + printf( __( 'Tel: %s', 'woocommerce-subscriptions' ), $order->billing_phone ); + echo "\n"; +} + +wc_get_template( 'emails/plain/email-addresses.php', array( 'order' => $order ) ); + +echo "\n****************************************************\n\n"; + +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/customer-renewal-invoice.php b/templates/emails/plain/customer-renewal-invoice.php new file mode 100644 index 0000000..58c17c9 --- /dev/null +++ b/templates/emails/plain/customer-renewal-invoice.php @@ -0,0 +1,79 @@ +status == 'pending' ) { + // translators: %1$s: name of the blog, %2$s: link to checkout payment url, note: no full stop due to url at the end + printf( esc_html_x( 'An invoice has been created for you to renew your subscription with %1$s. To pay for this invoice please use the following link: %2$s', 'In customer renewal invoice email', 'woocommerce-subscriptions' ), esc_html( get_bloginfo( 'name' ) ), esc_attr( $order->get_checkout_payment_url() ) ) . "\n\n"; +} elseif ( 'failed' == $order->status ) { + // translators: %1$s: name of the blog, %2$s: link to checkout payment url, note: no full stop due to url at the end + printf( esc_html_x( 'The automatic payment to renew your subscription with %1$s has failed. To reactivate the subscription, please login and pay for the renewal from your account page: %2$s', 'In customer renewal invoice email', 'woocommerce-subscriptions' ), esc_html( get_bloginfo( 'name' ) ), esc_attr( $order->get_checkout_payment_url() ) ); +} + +echo "****************************************************\n\n"; + +do_action( 'woocommerce_email_before_order_table', $order, false, true ); + +printf( __( 'Order number: %s', 'woocommerce-subscriptions' ), $order->get_order_number() ) . "\n"; +printf( __( 'Order date: %s', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), strtotime( $order->order_date ) ) ) . "\n"; + +do_action( 'woocommerce_email_order_meta', $order, false, true ); + +echo "\n"; + +switch ( $order->status ) { + case 'completed' : + echo WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => $order->is_download_permitted(), + 'show_sku' => false, + 'show_purchase_note' => true, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, + ) ); + break; + case 'processing' : + echo WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => $order->is_download_permitted(), + 'show_sku' => true, + 'show_purchase_note' => true, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, + ) ); + break; + default : + echo WC_Subscriptions_Email::email_order_items_table( $order, array( + 'show_download_links' => $order->is_download_permitted(), + 'show_sku' => true, + 'show_purchase_note' => false, + 'show_image' => '', + 'image_size' => '', + 'plain_text' => true, + ) ); + break; +} + +echo "----------\n\n"; + +if ( $totals = $order->get_order_item_totals() ) { + foreach ( $totals as $total ) { + echo $total['label'] . "\t " . $total['value'] . "\n"; + } +} + +echo "\n****************************************************\n\n"; + +do_action( 'woocommerce_email_after_order_table', $order, false, true ); + +echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ); diff --git a/templates/emails/plain/subscription-info.php b/templates/emails/plain/subscription-info.php new file mode 100644 index 0000000..4d66722 --- /dev/null +++ b/templates/emails/plain/subscription-info.php @@ -0,0 +1,34 @@ +get_order_number() ) . "\n"; + // translators: placeholder is either view or edit url for the subscription + printf( _x( 'View Subscription: %s', 'in plain emails for subscription information', 'woocommerce-subscriptions' ), $is_admin_email ? wcs_get_edit_post_link( $subscription->id ) : $subscription->get_view_order_url() ) . "\n"; + // translators: placeholder is localised start date + printf( _x( 'Start Date: %s', 'in plain emails for subscription information', 'woocommerce-subscriptions' ), date_i18n( wc_date_format(), $subscription->get_time( 'start', 'site' ) ) ) . "\n"; + + $end_date = ( 0 < $subscription->get_time( 'end' ) ) ?date_i18n( wc_date_format(), $subscription->get_time( 'end', 'site' ) ) : _x( 'When Cancelled', 'Used as end date for an indefinite subscription', 'woocommerce-subscriptions' ); + // translators: placeholder is localised end date, or "when cancelled" + printf( _x( 'End Date: %s', 'in plain emails for subscription information', 'woocommerce-subscriptions' ), $end_date ) . "\n"; + // translators: placeholder is the formatted order total for the subscription + echo _x( 'Price: %s', 'in plain emails for subscription information', 'woocommerce-subscriptions' ) . ': ' . $subscription->get_formatted_order_total(); + echo "\n\n"; + } + + echo "\n****************************************************\n\n"; +} diff --git a/templates/emails/subscription-info.php b/templates/emails/subscription-info.php new file mode 100644 index 0000000..36d9154 --- /dev/null +++ b/templates/emails/subscription-info.php @@ -0,0 +1,35 @@ + + +

    + + + + + + + + + + + + + + + + + + + +
    get_order_number() ); ?>get_time( 'start', 'site' ) ) ); ?>get_time( 'end' ) ) ? date_i18n( wc_date_format(), $subscription->get_time( 'end', 'site' ) ) : _x( 'When Cancelled', 'Used as end date for an indefinite subscription', 'woocommerce-subscriptions' ) ); ?>get_formatted_order_total() ); ?>
    + diff --git a/templates/myaccount/my-subscriptions.php b/templates/myaccount/my-subscriptions.php new file mode 100644 index 0000000..2c46902 --- /dev/null +++ b/templates/myaccount/my-subscriptions.php @@ -0,0 +1,79 @@ + + + + +
    +

    +
    + + + + + + + + + + + + + + + get_item_count(); + + ?> + + + + + + + + + + + diff --git a/templates/myaccount/related-subscriptions.php b/templates/myaccount/related-subscriptions.php new file mode 100644 index 0000000..f0545f7 --- /dev/null +++ b/templates/myaccount/related-subscriptions.php @@ -0,0 +1,52 @@ + +
    +

    +
    + + + + + + + + + + + + $subscription ) : ?> + + + + + + + + + + + + diff --git a/templates/myaccount/subscriptions.php b/templates/myaccount/subscriptions.php new file mode 100644 index 0000000..28a4d2b --- /dev/null +++ b/templates/myaccount/subscriptions.php @@ -0,0 +1,14 @@ + Subscriptions page + * + * @author Prospress + * @category WooCommerce Subscriptions/Templates + * @version 2.0.15 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly +} + +WC_Subscriptions::get_my_subscriptions_template(); diff --git a/templates/myaccount/view-subscription.php b/templates/myaccount/view-subscription.php new file mode 100644 index 0000000..0010a20 --- /dev/null +++ b/templates/myaccount/view-subscription.php @@ -0,0 +1,306 @@ +query_vars['view-subscription'] ) || 'shop_subscription' != get_post_type( absint( $wp->query_vars['view-subscription'] ) ) || ! current_user_can( 'view_order', absint( $wp->query_vars['view-subscription'] ) ) ) { + echo '
    ' . esc_html__( 'Invalid Subscription.', 'woocommerce-subscriptions' ) . ' '. esc_html__( 'My Account', 'woocommerce-subscriptions' ) .'' . '
    '; + return; + } + + $subscription = wcs_get_subscription( $wp->query_vars['view-subscription'] ); +} + +wc_print_notices(); +?> + + + + + + + + + + + _x( 'Last Payment Date', 'admin subscription table header', 'woocommerce-subscriptions' ), + 'next_payment' => _x( 'Next Payment Date', 'admin subscription table header', 'woocommerce-subscriptions' ), + 'end' => _x( 'End Date', 'table heading', 'woocommerce-subscriptions' ), + 'trial end' => _x( 'Trial End Date', 'admin subscription table header', 'woocommerce-subscriptions' ), + ) as $date_type => $date_title ) : ?> + get_date( $date_type ); ?> + + + + + + + + + + + + + + +
    get_status() ) ); ?>
    get_date_to_display( 'start' ) ); ?>
    get_date_to_display( $date_type ) ); ?>
    + $action ) : ?> + + +
    +get_customer_order_notes() ) : + ?> +

    +
      + +
    1. +
      +
      +

      comment_date ) ) ); ?>

      +
      + comment_content ) ) ); ?> +
      +
      +
      +
      +
      +
    2. + +
    + + +

    + + + + + + + + + + + + get_items() ) > 0 ) { + + foreach ( $subscription->get_items() as $item_id => $item ) { + $_product = apply_filters( 'woocommerce_subscriptions_order_item_product', $subscription->get_product_from_item( $item ), $item ); + $item_meta = wcs_get_order_item_meta( $item, $_product ); + if ( apply_filters( 'woocommerce_order_item_visible', true, $item ) ) { + ?> + + + + + + + + has_status( array( 'completed', 'processing' ) ) && ( $purchase_note = get_post_meta( $_product->id, '_purchase_note', true ) ) ) { + ?> + + + + + + + get_total_refunded() ) { + $has_refund = true; + } + + if ( $totals = $subscription->get_order_item_totals() ) { + foreach ( $totals as $key => $total ) { + $value = $total['value']; + + // Check for refund + if ( $has_refund && 'order_total' === $key ) { + $refunded_tax_del = ''; + $refunded_tax_ins = ''; + + // Tax for inclusive prices + if ( wc_tax_enabled() && 'incl' == $subscription->tax_display_cart ) { + + $tax_del_array = array(); + $tax_ins_array = array(); + + if ( 'itemized' == get_option( 'woocommerce_tax_total_display' ) ) { + + foreach ( $subscription->get_tax_totals() as $code => $tax ) { + $tax_del_array[] = sprintf( '%s %s', $tax->formatted_amount, $tax->label ); + $tax_ins_array[] = sprintf( '%s %s', wc_price( $tax->amount - $subscription->get_total_tax_refunded_by_rate_id( $tax->rate_id ), array( 'currency' => $subscription->get_order_currency() ) ), $tax->label ); + } + } else { + $tax_del_array[] = sprintf( '%s %s', wc_price( $subscription->get_total_tax(), array( 'currency' => $subscription->get_order_currency() ) ), WC()->countries->tax_or_vat() ); + $tax_ins_array[] = sprintf( '%s %s', wc_price( $subscription->get_total_tax() - $subscription->get_total_tax_refunded(), array( 'currency' => $subscription->get_order_currency() ) ), WC()->countries->tax_or_vat() ); + } + + if ( ! empty( $tax_del_array ) ) { + // translators: placeholder is price string, denotes tax included in cart/order total + $refunded_tax_del .= ' ' . sprintf( _x( '(Includes %s)', 'includes tax', 'woocommerce-subscriptions' ), implode( ', ', $tax_del_array ) ); + } + + if ( ! empty( $tax_ins_array ) ) { + // translators: placeholder is price string, denotes tax included in cart/order total + $refunded_tax_ins .= ' ' . sprintf( _x( '(Includes %s)', 'includes tax', 'woocommerce-subscriptions' ), implode( ', ', $tax_ins_array ) ); + } + } + + $value = '' . strip_tags( $subscription->get_formatted_order_total() ) . $refunded_tax_del . '' . wc_price( $subscription->get_total() - $total_refunded, array( 'currency' => $subscription->get_order_currency() ) ) . $refunded_tax_ins . ''; + } + ?> + + + + + + + + + + customer_note ) { ?> + + + + + + +
     
    × + is_visible() ) { + echo esc_html( apply_filters( 'woocommerce_order_item_name', $item['name'], $item ) ); + } else { + echo wp_kses_post( apply_filters( 'woocommerce_order_item_name', sprintf( '%s', get_permalink( $item['product_id'] ), $item['name'] ), $item ) ); + } + + echo wp_kses_post( apply_filters( 'woocommerce_order_item_quantity_html', ' ' . sprintf( '× %s', $item['qty'] ) . '', $item ) ); + + // Allow other plugins to add additional product information here + do_action( 'woocommerce_order_item_meta_start', $item_id, $item, $subscription ); + + $item_meta->display(); + + if ( $_product && $_product->exists() && $_product->is_downloadable() && $subscription->is_download_permitted() ) { + + $download_files = $subscription->get_item_downloads( $item ); + $i = 0; + $links = array(); + + foreach ( $download_files as $download_id => $file ) { + $i++; + // translators: %1$s is the number of the file (only in plural!), %2$s: the name of the file + $link_text = sprintf( _nx( 'Download file: %2$s', 'Download file %1$s: %2$s', count( $download_files ), 'Used as link text in view-subscription template', 'woocommerce-subscriptions' ), $i, $file['name'] ); + $links[] = '' . esc_html( $link_text ) . ''; + } + + echo '
    ' . wp_kses_post( implode( '
    ', $links ) ); + } + + // Allow other plugins to add additional product information here + do_action( 'woocommerce_order_item_meta_end', $item_id, $item, $subscription ); + ?> +
    + get_formatted_line_subtotal( $item ) ); ?> +
    >
    - $subscription->get_order_currency() ) ) ); ?>
    customer_note ) ); ?>
    + + + +
    +

    +
    + + billing_email ) { + // translators: there is markup here, hence can't use Email: %s + echo ''; + } + + if ( $subscription->billing_phone ) { + // translators: there is markup here, hence can't use Email: %s + echo ''; + } + + // Additional customer details hook + do_action( 'woocommerce_order_details_after_customer_details', $subscription ); + ?> +
    ' . esc_html_x( 'Email', 'heading in customer details on subscription detail page', 'woocommerce-subscriptions' ) . '' . esc_html( $subscription->billing_email ) . '
    ' . esc_html_x( 'Tel', 'heading in customer details on subscription detail page', 'woocommerce-subscriptions' ) . '' . esc_html( $subscription->billing_phone ) . '
    + +needs_shipping_address() && get_option( 'woocommerce_calc_shipping' ) !== 'no' ) : ?> + +
    + +
    + + + +
    +

    +
    +
    + get_formatted_billing_address() ) { + echo esc_html_x( 'N/A', 'no information about something', 'woocommerce-subscriptions' ); + } else { + echo wp_kses_post( $subscription->get_formatted_billing_address() ); + } + ?> +
    + +needs_shipping_address() && get_option( 'woocommerce_calc_shipping' ) !== 'no' ) : ?> + +
    + +
    + +
    +

    +
    +
    + get_formatted_shipping_address() ) { + echo esc_html_x( 'N/A', 'no information about something', 'woocommerce-subscriptions' ); + } else { + echo wp_kses_post( $subscription->get_formatted_shipping_address() ); + } + ?> +
    + +
    + +
    + + + +
    diff --git a/templates/single-product/add-to-cart/subscription.php b/templates/single-product/add-to-cart/subscription.php new file mode 100644 index 0000000..b8c5d7a --- /dev/null +++ b/templates/single-product/add-to-cart/subscription.php @@ -0,0 +1,72 @@ +is_purchasable() && ( ! is_user_logged_in() || 'no' == $product->limit_subscriptions ) ) { + return; +} + +$user_id = get_current_user_id(); + +// Availability +$availability = $product->get_availability(); + +if ( $availability['availability'] ) : + echo wp_kses_post( apply_filters( 'woocommerce_stock_html', '

    '.$availability['availability'].'

    ', $availability['availability'] ) ); +endif; + +if ( ! $product->is_in_stock() ) : ?> + + + + + + + + is_purchasable() && 0 != $user_id && 'no' != $product->limit_subscriptions && ( ( 'active' == $product->limit_subscriptions && wcs_user_has_subscription( $user_id, $product->id, 'on-hold' ) ) || $user_has_subscription = wcs_user_has_subscription( $user_id, $product->id, $product->limit_subscriptions ) ) ) : ?> + limit_subscriptions && $user_has_subscription && ! wcs_user_has_subscription( $user_id, $product->id, 'active' ) && ! wcs_user_has_subscription( $user_id, $product->id, 'on-hold' ) ) : // customer has an inactive subscription, maybe offer the renewal button ?> + id ); ?> + + + + +

    + + +
    + + + + + + is_sold_individually() ) { + woocommerce_quantity_input( array( + 'min_value' => apply_filters( 'woocommerce_quantity_input_min', 1, $product ), + 'max_value' => apply_filters( 'woocommerce_quantity_input_max', $product->backorders_allowed() ? '' : $product->get_stock_quantity(), $product ), + ) ); + } + ?> + + + + + +
    + + + + + diff --git a/templates/single-product/add-to-cart/variable-subscription.php b/templates/single-product/add-to-cart/variable-subscription.php new file mode 100644 index 0000000..9e56100 --- /dev/null +++ b/templates/single-product/add-to-cart/variable-subscription.php @@ -0,0 +1,83 @@ + + +
    + + + +

    + + is_purchasable() && 0 != $user_id && 'no' != $product->limit_subscriptions && ( ( 'active' == $product->limit_subscriptions && wcs_user_has_subscription( $user_id, $product->id, 'on-hold' ) ) || $user_has_subscription = wcs_user_has_subscription( $user_id, $product->id, $product->limit_subscriptions ) ) ) : ?> + limit_subscriptions && $user_has_subscription && ! wcs_user_has_subscription( $user_id, $product->id, 'active' ) && ! wcs_user_has_subscription( $user_id, $product->id, 'on-hold' ) ) : // customer has an inactive subscription, maybe offer the renewal button ?> + id ); ?> + + + + +

    + + + + + $options ) : ?> + + + + + + +
    + get_variation_default_attribute( $attribute_name ); + wc_dropdown_variation_attribute_options( array( 'options' => $options, 'attribute' => $attribute_name, 'product' => $product, 'selected' => $selected ) ); + echo wp_kses( end( $attribute_keys ) === $attribute_name ? '' . __( 'Clear selection', 'woocommerce-subscriptions' ) . '' : '', array( 'a' => array( 'class' => array(), 'href' => array() ) ) ); + ?> +
    + + + + + + + + + +
    + + diff --git a/wcs-functions.php b/wcs-functions.php new file mode 100644 index 0000000..eaa378d --- /dev/null +++ b/wcs-functions.php @@ -0,0 +1,564 @@ +prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s LIMIT 1;", 'shop_subscription' ); + + // query is the fastest, every other built in method uses this. Plus, the return value is the number of rows found + $num_rows_found = $wpdb->query( $sql ); + + return ( 0 !== $num_rows_found ) ? true: false; +} + +/** + * Main function for returning subscriptions. Wrapper for the wc_get_order() method. + * + * @since 2.0 + * @param mixed $the_subscription Post object or post ID of the order. + * @return WC_Subscription + */ +function wcs_get_subscription( $the_subscription ) { + + if ( is_object( $the_subscription ) && wcs_is_subscription( $the_subscription ) ) { + $the_subscription = $the_subscription->id; + } + + $subscription = WC()->order_factory->get_order( $the_subscription ); + + if ( ! wcs_is_subscription( $subscription ) ) { + $subscription = false; + } + + return apply_filters( 'wcs_get_subscription', $subscription ); +} + +/** + * Create a new subscription + * + * Returns a new WC_Subscription object on success which can then be used to add additional data. + * + * @return WC_Subscription | WP_Error A WC_Subscription on success or WP_Error object on failure + * @since 2.0 + */ +function wcs_create_subscription( $args = array() ) { + + $order = ( isset( $args['order_id'] ) ) ? wc_get_order( $args['order_id'] ) : null; + + if ( ! empty( $order ) && isset( $order->post->post_date ) ) { + $default_start_date = ( '0000-00-00 00:00:00' != $order->post->post_date_gmt ) ? $order->post->post_date_gmt : get_gmt_from_date( $order->post->post_date ); + } else { + $default_start_date = current_time( 'mysql', true ); + } + + $default_args = array( + 'status' => '', + 'order_id' => 0, + 'customer_note' => null, + 'customer_id' => ( ! empty( $order ) ) ? $order->get_user_id() : null, + 'start_date' => $default_start_date, + 'created_via' => ( ! empty( $order ) ) ? $order->created_via : '', + 'order_version' => ( ! empty( $order ) ) ? $order->order_version : WC_VERSION, + 'currency' => ( ! empty( $order ) ) ? $order->order_currency : get_woocommerce_currency(), + 'prices_include_tax' => ( ! empty( $order ) ) ? ( ( $order->prices_include_tax ) ? 'yes' : 'no' ) : get_option( 'woocommerce_prices_include_tax' ), // we don't use wc_prices_include_tax() here because WC doesn't use it in wc_create_order(), not 100% sure why it doesn't also check the taxes are enabled, but there could forseeably be a reason + ); + + $args = wp_parse_args( $args, $default_args ); + $subscription_data = array(); + + // validate the start_date field + if ( ! is_string( $args['start_date'] ) || false === wcs_is_datetime_mysql_format( $args['start_date'] ) ) { + return new WP_Error( 'woocommerce_subscription_invalid_start_date_format', _x( 'Invalid date. The date must be a string and of the format: "Y-m-d H:i:s".', 'Error message while creating a subscription', 'woocommerce-subscriptions' ) ); + } else if ( strtotime( $args['start_date'] ) > current_time( 'timestamp', true ) ) { + return new WP_Error( 'woocommerce_subscription_invalid_start_date', _x( 'Subscription start date must be before current day.', 'Error message while creating a subscription', 'woocommerce-subscriptions' ) ); + } + + // check customer id is set + if ( empty( $args['customer_id'] ) || ! is_numeric( $args['customer_id'] ) || $args['customer_id'] <= 0 ) { + return new WP_Error( 'woocommerce_subscription_invalid_customer_id', _x( 'Invalid subscription customer_id.', 'Error message while creating a subscription', 'woocommerce-subscriptions' ) ); + } + + // check the billing period + if ( empty( $args['billing_period'] ) || ! in_array( strtolower( $args['billing_period'] ), array_keys( wcs_get_subscription_period_strings() ) ) ) { + return new WP_Error( 'woocommerce_subscription_invalid_billing_period', __( 'Invalid subscription billing period given.', 'woocommerce-subscriptions' ) ); + } + + // check the billing interval + if ( empty( $args['billing_interval'] ) || ! is_numeric( $args['billing_interval'] ) || absint( $args['billing_interval'] ) <= 0 ) { + return new WP_Error( 'woocommerce_subscription_invalid_billing_interval', __( 'Invalid subscription billing interval given. Must be an integer greater than 0.', 'woocommerce-subscriptions' ) ); + } + + $subscription_data['post_type'] = 'shop_subscription'; + $subscription_data['post_status'] = 'wc-' . apply_filters( 'woocommerce_default_subscription_status', 'pending' ); + $subscription_data['ping_status'] = 'closed'; + $subscription_data['post_author'] = 1; + $subscription_data['post_password'] = uniqid( 'order_' ); + // translators: Order date parsed by strftime + $post_title_date = strftime( _x( '%b %d, %Y @ %I:%M %p', 'Used in subscription post title. "Subscription renewal order - "', 'woocommerce-subscriptions' ) ); + // translators: placeholder is order date parsed by strftime + $subscription_data['post_title'] = sprintf( _x( 'Subscription – %s', 'The post title for the new subscription', 'woocommerce-subscriptions' ), $post_title_date ); + $subscription_data['post_date_gmt'] = $args['start_date']; + $subscription_data['post_date'] = get_date_from_gmt( $args['start_date'] ); + + if ( $args['order_id'] > 0 ) { + $subscription_data['post_parent'] = absint( $args['order_id'] ); + } + + if ( ! is_null( $args['customer_note'] ) && ! empty( $args['customer_note'] ) ) { + $subscription_data['post_excerpt'] = $args['customer_note']; + } + + // Only set the status if creating a new subscription, use wcs_update_subscription to update the status + if ( $args['status'] ) { + if ( ! in_array( 'wc-' . $args['status'], array_keys( wcs_get_subscription_statuses() ) ) ) { + return new WP_Error( 'woocommerce_invalid_subscription_status', __( 'Invalid subscription status given.', 'woocommerce-subscriptions' ) ); + } + $subscription_data['post_status'] = 'wc-' . $args['status']; + } + + $subscription_id = wp_insert_post( apply_filters( 'woocommerce_new_subscription_data', $subscription_data, $args ), true ); + + if ( is_wp_error( $subscription_id ) ) { + return $subscription_id; + } + + // Default order meta data. + update_post_meta( $subscription_id, '_order_key', 'wc_' . apply_filters( 'woocommerce_generate_order_key', uniqid( 'order_' ) ) ); + update_post_meta( $subscription_id, '_order_currency', $args['currency'] ); + update_post_meta( $subscription_id, '_prices_include_tax', $args['prices_include_tax'] ); + update_post_meta( $subscription_id, '_created_via', sanitize_text_field( $args['created_via'] ) ); + + // add/update the billing + update_post_meta( $subscription_id, '_billing_period', $args['billing_period'] ); + update_post_meta( $subscription_id, '_billing_interval', absint( $args['billing_interval'] ) ); + + update_post_meta( $subscription_id, '_customer_user', $args['customer_id'] ); + update_post_meta( $subscription_id, '_order_version', $args['order_version'] ); + + return new WC_Subscription( $subscription_id ); +} + +/** + * Return an array of subscription status types, similar to @see wc_get_order_statuses() + * + * @since 2.0 + * @return array + */ +function wcs_get_subscription_statuses() { + + $subscription_statuses = array( + 'wc-pending' => _x( 'Pending', 'Subscription status', 'woocommerce-subscriptions' ), + 'wc-active' => _x( 'Active', 'Subscription status', 'woocommerce-subscriptions' ), + 'wc-on-hold' => _x( 'On hold', 'Subscription status', 'woocommerce-subscriptions' ), + 'wc-cancelled' => _x( 'Cancelled', 'Subscription status', 'woocommerce-subscriptions' ), + 'wc-switched' => _x( 'Switched', 'Subscription status', 'woocommerce-subscriptions' ), + 'wc-expired' => _x( 'Expired', 'Subscription status', 'woocommerce-subscriptions' ), + 'wc-pending-cancel' => _x( 'Pending Cancellation', 'Subscription status', 'woocommerce-subscriptions' ), + ); + + return apply_filters( 'wcs_subscription_statuses', $subscription_statuses ); +} + +/** + * Get the nice name for a subscription's status + * + * @since 2.0 + * @param string $status + * @return string + */ +function wcs_get_subscription_status_name( $status ) { + + if ( ! is_string( $status ) ) { + return new WP_Error( 'woocommerce_subscription_wrong_status_format', __( 'Can not get status name. Status is not a string.', 'woocommerce-subscriptions' ) ); + } + + $statuses = wcs_get_subscription_statuses(); + + $sanitized_status_key = wcs_sanitize_subscription_status_key( $status ); + + // if the sanitized status key is not in the list of filtered subscription names, return the + // original key, without the wc- + $status_name = isset( $statuses[ $sanitized_status_key ] ) ? $statuses[ $sanitized_status_key ] : $status; + + return apply_filters( 'woocommerce_subscription_status_name', $status_name, $status ); +} + +/** + * Helper function to return a localised display name for an address type + * + * @param string $address_type the type of address (shipping / billing) + * + * @return string + */ +function wcs_get_address_type_to_display( $address_type ) { + if ( ! is_string( $address_type ) ) { + return new WP_Error( 'woocommerce_subscription_wrong_address_type_format', __( 'Can not get address type display name. Address type is not a string.', 'woocommerce-subscriptions' ) ); + } + + $address_types = apply_filters( 'woocommerce_subscription_address_types', array( + 'shipping' => __( 'Shipping Address', 'woocommerce-subscriptions' ), + 'billing' => __( 'Billing Address', 'woocommerce-subscriptions' ), + ) ); + + // if we can't find the address type, return the raw key + $address_type_display = isset( $address_types[ $address_type ] ) ? $address_types[ $address_type ] : $address_type; + + return apply_filters( 'woocommerce_subscription_address_type_display', $address_type_display, $address_type ); +} + +/** + * Returns an array of subscription dates + * + * @since 2.0 + * @return array + */ +function wcs_get_subscription_date_types() { + + $dates = array( + 'start' => _x( 'Start Date', 'table heading', 'woocommerce-subscriptions' ), + 'trial_end' => _x( 'Trial End', 'table heading', 'woocommerce-subscriptions' ), + 'next_payment' => _x( 'Next Payment', 'table heading', 'woocommerce-subscriptions' ), + 'last_payment' => _x( 'Last Payment', 'table heading', 'woocommerce-subscriptions' ), + 'end' => _x( 'End Date', 'table heading', 'woocommerce-subscriptions' ), + ); + + return apply_filters( 'woocommerce_subscription_dates', $dates ); +} + +/** + * Get the meta key value for storing a date in the subscription's post meta table. + * + * @param string $date_type Internally, 'trial_end', 'next_payment' or 'end', but can be any string + * @since 2.0 + */ +function wcs_get_date_meta_key( $date_type ) { + if ( ! is_string( $date_type ) ) { + return new WP_Error( 'woocommerce_subscription_wrong_date_type_format', __( 'Date type is not a string.', 'woocommerce-subscriptions' ) ); + } elseif ( empty( $date_type ) ) { + return new WP_Error( 'woocommerce_subscription_wrong_date_type_format', __( 'Date type can not be an empty string.', 'woocommerce-subscriptions' ) ); + } + return apply_filters( 'woocommerce_subscription_date_meta_key_prefix', sprintf( '_schedule_%s', $date_type ), $date_type ); +} + +/** + * Utility function to standardise status keys: + * - turns 'pending' into 'wc-pending'. + * - turns 'wc-pending' into 'wc-pending' + * + * @param string $status_key The status key going in + * @return string Status key guaranteed to have 'wc-' at the beginning + */ +function wcs_sanitize_subscription_status_key( $status_key ) { + if ( ! is_string( $status_key ) || empty( $status_key ) ) { + return ''; + } + $status_key = ( 'wc-' === substr( $status_key, 0, 3 ) ) ? $status_key : sprintf( 'wc-%s', $status_key ); + return $status_key; +} + +/** + * A general purpose function for grabbing an array of subscriptions in form of post_id => WC_Subscription + * + * The $args parameter is based on the parameter of the same name used by the core WordPress @see get_posts() function. + * It can be used to choose which subscriptions should be returned by the function, how many subscriptions should be returned + * and in what order those subscriptions should be returned. + * + * @param array $args A set of name value pairs to determine the return value. + * 'subscriptions_per_page' The number of subscriptions to return. Set to -1 for unlimited. Default 10. + * 'offset' An optional number of subscription to displace or pass over. Default 0. + * 'orderby' The field which the subscriptions should be ordered by. Can be 'start_date', 'trial_end_date', 'end_date', 'status' or 'order_id'. Defaults to 'start_date'. + * 'order' The order of the values returned. Can be 'ASC' or 'DESC'. Defaults to 'DESC' + * 'customer_id' The user ID of a customer on the site. + * 'product_id' The post ID of a WC_Product_Subscription, WC_Product_Variable_Subscription or WC_Product_Subscription_Variation object + * 'order_id' The post ID of a shop_order post/WC_Order object which was used to create the subscription + * 'subscription_status' Any valid subscription status. Can be 'any', 'active', 'cancelled', 'suspended', 'expired', 'pending' or 'trash'. Defaults to 'any'. + * @return array Subscription details in post_id => WC_Subscription form. + * @since 2.0 + */ +function wcs_get_subscriptions( $args ) { + global $wpdb; + + $args = wp_parse_args( $args, array( + 'subscriptions_per_page' => 10, + 'paged' => 1, + 'offset' => 0, + 'orderby' => 'start_date', + 'order' => 'DESC', + 'customer_id' => 0, + 'product_id' => 0, + 'variation_id' => 0, + 'order_id' => 0, + 'subscription_status' => 'any', + 'meta_query_relation' => 'AND', + ) + ); + + // if order_id is not a shop_order + if ( 0 !== $args['order_id'] && 'shop_order' !== get_post_type( $args['order_id'] ) ) { + return array(); + } + + // Make sure status starts with 'wc-' + if ( ! in_array( $args['subscription_status'], array( 'any', 'trash' ) ) ) { + $args['subscription_status'] = wcs_sanitize_subscription_status_key( $args['subscription_status'] ); + } + + // Prepare the args for WP_Query + $query_args = array( + 'post_type' => 'shop_subscription', + 'post_status' => $args['subscription_status'], + 'posts_per_page' => $args['subscriptions_per_page'], + 'paged' => $args['paged'], + 'offset' => $args['offset'], + 'order' => $args['order'], + 'fields' => 'ids', + 'meta_query' => array(), // just in case we need to filter or order by meta values later + ); + + // Maybe only get subscriptions created by a certain order + if ( 0 != $args['order_id'] && is_numeric( $args['order_id'] ) ) { + $query_args['post_parent'] = $args['order_id']; + } + + // Map subscription specific orderby values to internal/WordPress keys + switch ( $args['orderby'] ) { + case 'status' : + $query_args['orderby'] = 'post_status'; + break; + case 'start_date' : + $query_args['orderby'] = 'date'; + break; + case 'trial_end_date' : + case 'end_date' : + // We need to orderby post meta value: http://www.paulund.co.uk/order-meta-query + $query_args = array_merge( $query_args, array( + 'orderby' => 'meta_value', + 'meta_key' => wcs_get_date_meta_key( $args['orderby'] ), + 'meta_type' => 'DATETIME', + ) ); + $query_args['meta_query'][] = array( + 'key' => wcs_get_date_meta_key( $args['orderby'] ), + 'value' => 'EXISTS', + 'type' => 'DATETIME', + ); + break; + default : + $query_args['orderby'] = $args['orderby']; + break; + } + + // Maybe filter to a specific user + if ( 0 != $args['customer_id'] && is_numeric( $args['customer_id'] ) ) { + $query_args['meta_query'][] = array( + 'key' => '_customer_user', + 'value' => $args['customer_id'], + 'type' => 'numeric', + 'compare' => ( is_array( $args['customer_id'] ) ) ? 'IN' : '=', + ); + }; + + // We need to restrict subscriptions to those which contain a certain product/variation + if ( ( 0 != $args['product_id'] && is_numeric( $args['product_id'] ) ) || ( 0 != $args['variation_id'] && is_numeric( $args['variation_id'] ) ) ) { + $query_args['post__in'] = wcs_get_subscriptions_for_product( array( $args['product_id'], $args['variation_id'] ) ); + } + + if ( ! empty( $query_args['meta_query'] ) ) { + $query_args['meta_query']['relation'] = $args['meta_query_relation']; + } + + $query_args = apply_filters( 'woocommerce_get_subscriptions_query_args', $query_args, $args ); + + $subscription_post_ids = get_posts( $query_args ); + + $subscriptions = array(); + + foreach ( $subscription_post_ids as $post_id ) { + $subscriptions[ $post_id ] = wcs_get_subscription( $post_id ); + } + + return apply_filters( 'woocommerce_got_subscriptions', $subscriptions, $args ); +} + +/** + * Get subscriptions that contain a certain product, specified by ID. + * + * @param int | array $product_ids Either the post ID of a product or variation or an array of product or variation IDs + * @param string $fields The fields to return, either "ids" to receive only post ID's for the match subscriptions, or "subscription" to receive WC_Subscription objects + * @return array + * @since 2.0 + */ +function wcs_get_subscriptions_for_product( $product_ids, $fields = 'ids' ) { + global $wpdb; + + // If we have an array of IDs, convert them to a comma separated list and sanatise them to make sure they're all integers + if ( is_array( $product_ids ) ) { + $ids_for_query = implode( "', '", array_map( 'absint', array_unique( $product_ids ) ) ); + } else { + $ids_for_query = absint( $product_ids ); + } + + $subscription_ids = $wpdb->get_col( " + SELECT DISTINCT order_items.order_id FROM {$wpdb->prefix}woocommerce_order_items as order_items + LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS itemmeta ON order_items.order_item_id = itemmeta.order_item_id + LEFT JOIN {$wpdb->posts} AS posts ON order_items.order_id = posts.ID + WHERE posts.post_type = 'shop_subscription' + AND itemmeta.meta_value IN ( '" . $ids_for_query . "' ) + AND itemmeta.meta_key IN ( '_variation_id', '_product_id' )" + ); + + $subscriptions = array(); + + foreach ( $subscription_ids as $post_id ) { + $subscriptions[ $post_id ] = ( 'ids' !== $fields ) ? wcs_get_subscription( $post_id ) : $post_id; + } + + return apply_filters( 'woocommerce_subscriptions_for_product', $subscriptions, $product_ids, $fields ); +} + +/** + * Get all subscription items which have a trial. + * + * @param mixed WC_Subscription|post_id + * @return array + * @since 2.0 + */ +function wcs_get_line_items_with_a_trial( $subscription_id ) { + + $subscription = ( is_object( $subscription_id ) ) ? $subscription_id : wcs_get_subscription( $subscription_id ); + $trial_items = array(); + + foreach ( $subscription->get_items() as $line_item_id => $line_item ) { + + if ( isset( $line_item['has_trial'] ) ) { + $trial_items[ $line_item_id ] = $line_item; + } + } + + return apply_filters( 'woocommerce_subscription_trial_line_items', $trial_items, $subscription_id ); +} + +/** + * Checks if the user can be granted the permission to remove a line item from the subscription. + * + * @param WC_Subscription $subscription An instance of a WC_Subscription object + * @since 2.0 + */ +function wcs_can_items_be_removed( $subscription ) { + $allow_remove = false; + + if ( sizeof( $subscription->get_items() ) > 1 && $subscription->payment_method_supports( 'subscription_amount_changes' ) && $subscription->has_status( array( 'active', 'on-hold', 'pending' ) ) ) { + $allow_remove = true; + } + + return apply_filters( 'wcs_can_items_be_removed', $allow_remove, $subscription ); +} + +/** + * Get the Product ID for an order's line item (only the product ID, not the variation ID, even if the order item + * is for a variation). + * + * @param int An order item ID + * @since 2.0 + */ +function wcs_get_order_items_product_id( $item_id ) { + global $wpdb; + + $product_id = $wpdb->get_var( $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta + WHERE order_item_id = %d + AND meta_key = '_product_id'", + $item_id + ) ); + + return $product_id; +} + +/** + * Get the variation ID for variation items or the product ID for non-variation items. + * + * When acting on cart items or order items, Subscriptions often needs to use an item's canonical product ID. For + * items representing a variation, that means the 'variation_id' value, if the item is not a variation, that means + * the 'product_id value. This function helps save keystrokes on the idiom to check if an item is to a variation or not. + * + * @param array $item Either a cart item or order/subscription line item + */ +function wcs_get_canonical_product_id( $item ) { + return ( ! empty( $item['variation_id'] ) ) ? $item['variation_id'] : $item['product_id']; +} + +/** + * Return an array statuses used to describe when a subscriptions has been marked as ending or has ended. + * + * @return array + * @since 2.0 + */ +function wcs_get_subscription_ended_statuses() { + return apply_filters( 'wcs_subscription_ended_statuses', array( 'cancelled', 'trash', 'expired', 'switched', 'pending-cancel' ) ); +} + +/** + * Returns true when on the My Account > View Subscription front end page. + * + * @return bool + * @since 2.0 + */ +function wcs_is_view_subscription_page() { + global $wp; + + return ( is_page( wc_get_page_id( 'myaccount' ) ) && isset( $wp->query_vars['view-subscription'] ) ) ? true : false; +} + diff --git a/woo-includes/class-wc-dependencies.php b/woo-includes/class-wc-dependencies.php new file mode 100644 index 0000000..2064ea6 --- /dev/null +++ b/woo-includes/class-wc-dependencies.php @@ -0,0 +1,28 @@ +file = $file; + $plugin->file_id = $file_id; + $plugin->product_id = $product_id; + + $woothemes_queued_updates[] = $plugin; + } +} + +/** + * Load installer for the WooThemes Updater. + * @return Object $api + */ +if ( ! class_exists( 'WooThemes_Updater' ) && ! function_exists( 'woothemes_updater_install' ) ) { + function woothemes_updater_install( $api, $action, $args ) { + $download_url = 'http://woodojo.s3.amazonaws.com/downloads/woothemes-updater/woothemes-updater.zip'; + + if ( 'plugin_information' != $action || + false !== $api || + ! isset( $args->slug ) || + 'woothemes-updater' != $args->slug + ) return $api; + + $api = new stdClass(); + $api->name = 'WooThemes Updater'; + $api->version = '1.0.0'; + $api->download_link = esc_url( $download_url ); + return $api; + } + + add_filter( 'plugins_api', 'woothemes_updater_install', 10, 3 ); +} + +/** + * WooUpdater Installation Prompts + */ +if ( ! class_exists( 'WooThemes_Updater' ) && ! function_exists( 'woothemes_updater_notice' ) ) { + + /** + * Display a notice if the "WooThemes Updater" plugin hasn't been installed. + * @return void + */ + function woothemes_updater_notice() { + $active_plugins = apply_filters( 'active_plugins', get_option('active_plugins' ) ); + if ( in_array( 'woothemes-updater/woothemes-updater.php', $active_plugins ) ) return; + + $slug = 'woothemes-updater'; + $install_url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=' . $slug ), 'install-plugin_' . $slug ); + $activate_url = 'plugins.php?action=activate&plugin=' . urlencode( 'woothemes-updater/woothemes-updater.php' ) . '&plugin_status=all&paged=1&s&_wpnonce=' . urlencode( wp_create_nonce( 'activate-plugin_woothemes-updater/woothemes-updater.php' ) ); + + $message = 'Install the WooThemes Helper plugin to get updates for your WooThemes plugins.'; + $is_downloaded = false; + $plugins = array_keys( get_plugins() ); + foreach ( $plugins as $plugin ) { + if ( strpos( $plugin, 'woothemes-updater.php' ) !== false ) { + $is_downloaded = true; + $message = 'Activate the WooThemes Helper plugin to get updates for your WooThemes plugins.'; + } + } + echo '

    ' . $message . '

    ' . "\n"; + } + + add_action( 'admin_notices', 'woothemes_updater_notice' ); +} + +/** + * Prevent conflicts with older versions + */ +if ( ! class_exists( 'WooThemes_Plugin_Updater' ) ) { + class WooThemes_Plugin_Updater { function init() {} } +} \ No newline at end of file diff --git a/woocommerce-subscriptions.php b/woocommerce-subscriptions.php new file mode 100644 index 0000000..dfde643 --- /dev/null +++ b/woocommerce-subscriptions.php @@ -0,0 +1,1269 @@ +. + * + * @package WooCommerce Subscriptions + * @author Prospress Inc. + * @since 1.0 + */ + +/** + * Required functions + */ +if ( ! function_exists( 'woothemes_queue_update' ) || ! function_exists( 'is_woocommerce_active' ) ) { + require_once( 'woo-includes/woo-functions.php' ); +} + +/** + * Plugin updates + */ +woothemes_queue_update( plugin_basename( __FILE__ ), '6115e6d7e297b623a169fdcf5728b224', '27147' ); + +/** + * Check if WooCommerce is active, and if it isn't, disable Subscriptions. + * + * @since 1.0 + */ +if ( ! is_woocommerce_active() || version_compare( get_option( 'woocommerce_db_version' ), '2.3', '<' ) ) { + add_action( 'admin_notices', 'WC_Subscriptions::woocommerce_inactive_notice' ); + return; +} + +require_once( 'wcs-functions.php' ); + +require_once( 'includes/class-wc-subscriptions-coupon.php' ); + +require_once( 'includes/class-wc-subscriptions-product.php' ); + +require_once( 'includes/admin/class-wc-subscriptions-admin.php' ); + +require_once( 'includes/class-wc-subscriptions-manager.php' ); + +require_once( 'includes/class-wc-subscriptions-cart.php' ); + +require_once( 'includes/class-wc-subscriptions-order.php' ); + +require_once( 'includes/class-wc-subscriptions-renewal-order.php' ); + +require_once( 'includes/class-wc-subscriptions-checkout.php' ); + +require_once( 'includes/class-wc-subscriptions-email.php' ); + +require_once( 'includes/class-wc-subscriptions-addresses.php' ); + +require_once( 'includes/class-wc-subscriptions-change-payment-gateway.php' ); + +require_once( 'includes/gateways/class-wc-subscriptions-payment-gateways.php' ); + +require_once( 'includes/gateways/paypal/class-wcs-paypal.php' ); + +require_once( 'includes/class-wc-subscriptions-switcher.php' ); + +require_once( 'includes/class-wc-subscriptions-synchroniser.php' ); + +require_once( 'includes/upgrades/class-wc-subscriptions-upgrader.php' ); + +require_once( 'includes/upgrades/class-wcs-upgrade-logger.php' ); + +require_once( 'includes/libraries/tlc-transients/tlc-transients.php' ); + +require_once( 'includes/libraries/action-scheduler/action-scheduler.php' ); + +require_once( 'includes/abstracts/abstract-wcs-scheduler.php' ); + +require_once( 'includes/class-wcs-action-scheduler.php' ); + +require_once( 'includes/abstracts/abstract-wcs-cache-manager.php' ); + +require_once( 'includes/class-wcs-cache-manager-tlc.php' ); + +require_once( 'includes/class-wcs-cart-renewal.php' ); + +require_once( 'includes/class-wcs-cart-resubscribe.php' ); + +require_once( 'includes/class-wcs-cart-initial-payment.php' ); + +require_once( 'includes/class-wcs-download-handler.php' ); + +/** + * The main subscriptions class. + * + * @since 1.0 + */ +class WC_Subscriptions { + + public static $name = 'subscription'; + + public static $activation_transient = 'woocommerce_subscriptions_activated'; + + public static $plugin_file = __FILE__; + + public static $version = '2.0.17'; + + private static $total_subscription_count = null; + + private static $scheduler; + + public static $cache; + + /** + * Set up the class, including it's hooks & filters, when the file is loaded. + * + * @since 1.0 + **/ + public static function init() { + + // Register our custom subscription order type after WC_Post_types::register_post_types() + add_action( 'init', __CLASS__ . '::register_order_types', 6 ); + + // Register our custom subscription order statuses before WC_Post_types::register_post_status() + add_action( 'init', __CLASS__ . '::register_post_status', 9 ); + + add_action( 'init', __CLASS__ . '::maybe_activate_woocommerce_subscriptions' ); + + register_deactivation_hook( __FILE__, __CLASS__ . '::deactivate_woocommerce_subscriptions' ); + + // Override the WC default "Add to Cart" text to "Sign Up Now" (in various places/templates) + add_filter( 'woocommerce_order_button_text', __CLASS__ . '::order_button_text' ); + add_action( 'woocommerce_subscription_add_to_cart', __CLASS__ . '::subscription_add_to_cart', 30 ); + add_action( 'woocommerce_variable-subscription_add_to_cart', __CLASS__ . '::variable_subscription_add_to_cart', 30 ); + add_action( 'wcopc_subscription_add_to_cart', __CLASS__ . '::wcopc_subscription_add_to_cart' ); // One Page Checkout compatibility + + // Ensure a subscription is never in the cart with products + add_filter( 'woocommerce_add_to_cart_validation', __CLASS__ . '::maybe_empty_cart', 10, 4 ); + + // Enqueue front-end styles, run after Storefront because it sets the styles to be empty + add_filter( 'woocommerce_enqueue_styles', __CLASS__ . '::enqueue_styles', 100, 1 ); + + // Load translation files + add_action( 'init', __CLASS__ . '::load_plugin_textdomain', 3 ); + + // Load dependent files + add_action( 'plugins_loaded', __CLASS__ . '::load_dependant_classes' ); + + // Attach hooks which depend on WooCommerce constants + add_action( 'plugins_loaded', __CLASS__ . '::attach_dependant_hooks' ); + + // WooCommerce 2.0 Notice + add_action( 'admin_notices', __CLASS__ . '::woocommerce_dependancy_notice' ); + + // Staging site or site migration notice + add_action( 'admin_notices', __CLASS__ . '::woocommerce_site_change_notice' ); + + // Add the "Settings | Documentation" links on the Plugins administration screen + add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), __CLASS__ . '::action_links' ); + + add_filter( 'action_scheduler_queue_runner_batch_size', __CLASS__ . '::action_scheduler_multisite_batch_size' ); + + add_action( 'in_plugin_update_message-' . plugin_basename( __FILE__ ), __CLASS__ . '::update_notice', 10, 2 ); + + $scheduler_class = apply_filters( 'woocommerce_subscriptions_scheduler', 'WCS_Action_Scheduler' ); + + self::$cache = WCS_Cache_Manager::get_instance(); + + self::$scheduler = new $scheduler_class(); + } + + /** + * Register core post types + * + * @since 2.0 + */ + public static function register_order_types() { + + wc_register_order_type( + 'shop_subscription', + apply_filters( 'woocommerce_register_post_type_subscription', + array( + // register_post_type() params + 'labels' => array( + 'name' => __( 'Subscriptions', 'woocommerce-subscriptions' ), + 'singular_name' => __( 'Subscription', 'woocommerce-subscriptions' ), + 'add_new' => _x( 'Add Subscription', 'custom post type setting', 'woocommerce-subscriptions' ), + 'add_new_item' => _x( 'Add New Subscription', 'custom post type setting', 'woocommerce-subscriptions' ), + 'edit' => _x( 'Edit', 'custom post type setting', 'woocommerce-subscriptions' ), + 'edit_item' => _x( 'Edit Subscription', 'custom post type setting', 'woocommerce-subscriptions' ), + 'new_item' => _x( 'New Subscription', 'custom post type setting', 'woocommerce-subscriptions' ), + 'view' => _x( 'View Subscription', 'custom post type setting', 'woocommerce-subscriptions' ), + 'view_item' => _x( 'View Subscription', 'custom post type setting', 'woocommerce-subscriptions' ), + 'search_items' => __( 'Search Subscriptions', 'woocommerce-subscriptions' ), + 'not_found' => self::get_not_found_text(), + 'not_found_in_trash' => _x( 'No Subscriptions found in trash', 'custom post type setting', 'woocommerce-subscriptions' ), + 'parent' => _x( 'Parent Subscriptions', 'custom post type setting', 'woocommerce-subscriptions' ), + 'menu_name' => __( 'Subscriptions', 'woocommerce-subscriptions' ), + ), + 'description' => __( 'This is where subscriptions are stored.', 'woocommerce-subscriptions' ), + 'public' => false, + 'show_ui' => true, + 'capability_type' => 'shop_order', + 'map_meta_cap' => true, + 'publicly_queryable' => false, + 'exclude_from_search' => true, + 'show_in_menu' => current_user_can( 'manage_woocommerce' ) ? 'woocommerce' : true, + 'hierarchical' => false, + 'show_in_nav_menus' => false, + 'rewrite' => false, + 'query_var' => false, + 'supports' => array( 'title', 'comments', 'custom-fields' ), + 'has_archive' => false, + + // wc_register_order_type() params + 'exclude_from_orders_screen' => true, + 'add_order_meta_boxes' => true, + 'exclude_from_order_count' => true, + 'exclude_from_order_views' => true, + 'exclude_from_order_webhooks' => true, + 'exclude_from_order_reports' => true, + 'exclude_from_order_sales_reports' => true, + 'class_name' => 'WC_Subscription', + ) + ) + ); + } + + /** + * Method that returns the not found text. If the user has created at least one subscription, the standard message + * will appear. If that's empty, the long, explanatory one will appear in the table. + * + * Filters: + * - woocommerce_subscriptions_not_empty: gets passed the option value. false or 'yes'. 'yes' means the subscriptions + * list is not empty, the user is familiar with how it works, and standard message appears. + * - woocommerce_subscriptions_not_found_label: gets the original message for other plugins to modify, in case + * they want to add more links, or modify any of the messages. + * @since 2.0 + * + * @return string what appears in the list table of the subscriptions + */ + private static function get_not_found_text() { + if ( true === apply_filters( 'woocommerce_subscriptions_not_empty', wcs_do_subscriptions_exist() ) ) { + $not_found_text = __( 'No Subscriptions found', 'woocommerce-subscriptions' ); + } else { + $not_found_text = '

    ' . __( 'Subscriptions will appear here for you to view and manage once purchased by a customer.', 'woocommerce-subscriptions' ) . '

    '; + // translators: placeholders are opening and closing link tags + $not_found_text .= '

    ' . sprintf( __( '%sLearn more about managing subscriptions »%s', 'woocommerce-subscriptions' ), '', '' ) . '

    '; + // translators: placeholders are opening and closing link tags + $not_found_text .= '

    ' . sprintf( __( '%sAdd a subscription product »%s', 'woocommerce-subscriptions' ), '', '' ) . '

    '; + } + + return apply_filters( 'woocommerce_subscriptions_not_found_label', $not_found_text ); + } + + /** + * Register our custom post statuses, used for order/subscription status + */ + public static function register_post_status() { + + $subscription_statuses = wcs_get_subscription_statuses(); + + $registered_statuses = apply_filters( 'woocommerce_subscriptions_registered_statuses', array( + 'wc-active' => _nx_noop( 'Active (%s)', 'Active (%s)', 'post status label including post count', 'woocommerce-subscriptions' ), + 'wc-switched' => _nx_noop( 'Switched (%s)', 'Switched (%s)', 'post status label including post count', 'woocommerce-subscriptions' ), + 'wc-expired' => _nx_noop( 'Expired (%s)', 'Expired (%s)', 'post status label including post count', 'woocommerce-subscriptions' ), + 'wc-pending-cancel' => _nx_noop( 'Pending Cancellation (%s)', 'Pending Cancellation (%s)', 'post status label including post count', 'woocommerce-subscriptions' ), + ) ); + + if ( is_array( $subscription_statuses ) && is_array( $registered_statuses ) ) { + + foreach ( $registered_statuses as $status => $label_count ) { + + register_post_status( $status, array( + 'label' => $subscription_statuses[ $status ], // use same label/translations as wcs_get_subscription_statuses() + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + 'label_count' => $label_count, + ) ); + } + } + } + + /** + * Enqueues stylesheet for the My Subscriptions table on the My Account page. + * + * @since 1.5 + */ + public static function enqueue_styles( $styles ) { + + if ( is_checkout() || is_cart() ) { + $styles['wcs-checkout'] = array( + 'src' => str_replace( array( 'http:', 'https:' ), '', plugin_dir_url( __FILE__ ) ) . 'assets/css/checkout.css', + 'deps' => 'wc-checkout', + 'version' => WC_VERSION, + 'media' => 'all', + ); + } elseif ( is_account_page() ) { + $styles['wcs-view-subscription'] = array( + 'src' => str_replace( array( 'http:', 'https:' ), '', plugin_dir_url( __FILE__ ) ) . 'assets/css/view-subscription.css', + 'deps' => 'woocommerce-smallscreen', + 'version' => self::$version, + 'media' => 'only screen and (max-width: ' . apply_filters( 'woocommerce_style_smallscreen_breakpoint', $breakpoint = '768px' ) . ')', + ); + } + + return $styles; + } + + /** + * Loads the my-subscriptions.php template on the My Account page. + * + * @since 1.0 + */ + public static function get_my_subscriptions_template() { + + $subscriptions = wcs_get_users_subscriptions(); + $user_id = get_current_user_id(); + + wc_get_template( 'myaccount/my-subscriptions.php', array( 'subscriptions' => $subscriptions, 'user_id' => $user_id ), '', plugin_dir_path( __FILE__ ) . 'templates/' ); + } + + /** + * Output a redirect URL when an item is added to the cart when a subscription was already in the cart. + * + * @since 1.0 + */ + public static function redirect_ajax_add_to_cart( $fragments ) { + + $data = array( + 'error' => true, + 'product_url' => WC()->cart->get_cart_url(), + ); + + return $data; + } + + /** + * When a subscription is added to the cart, remove other products/subscriptions to + * work with PayPal Standard, which only accept one subscription per checkout. + * + * If multiple purchase flag is set, allow them to be added at the same time. + * + * @since 1.0 + */ + public static function maybe_empty_cart( $valid, $product_id, $quantity, $variation_id = '' ) { + + $is_subscription = WC_Subscriptions_Product::is_subscription( $product_id ); + $cart_contains_subscription = WC_Subscriptions_Cart::cart_contains_subscription(); + $multiple_subscriptions_possible = WC_Subscriptions_Payment_Gateways::one_gateway_supports( 'multiple_subscriptions' ); + $manual_renewals_enabled = ( 'yes' == get_option( WC_Subscriptions_Admin::$option_prefix . '_accept_manual_renewals', 'no' ) ) ? true : false; + $canonical_product_id = ( ! empty( $variation_id ) ) ? $variation_id : $product_id; + + if ( $is_subscription && 'yes' != get_option( WC_Subscriptions_Admin::$option_prefix . '_multiple_purchase', 'no' ) ) { + + WC()->cart->empty_cart(); + + } elseif ( $is_subscription && wcs_cart_contains_renewal() && ! $multiple_subscriptions_possible && ! $manual_renewals_enabled ) { + + self::remove_subscriptions_from_cart(); + + self::add_notice( __( 'A subscription renewal has been removed from your cart. Multiple subscriptions can not be purchased at the same time.', 'woocommerce-subscriptions' ), 'notice' ); + + } elseif ( $is_subscription && $cart_contains_subscription && ! $multiple_subscriptions_possible && ! $manual_renewals_enabled && ! WC_Subscriptions_Cart::cart_contains_product( $canonical_product_id ) ) { + + self::remove_subscriptions_from_cart(); + + self::add_notice( __( 'A subscription has been removed from your cart. Due to payment gateway restrictions, different subscription products can not be purchased at the same time.', 'woocommerce-subscriptions' ), 'notice' ); + + } elseif ( $cart_contains_subscription && 'yes' != get_option( WC_Subscriptions_Admin::$option_prefix . '_multiple_purchase', 'no' ) ) { + + self::remove_subscriptions_from_cart(); + + self::add_notice( __( 'A subscription has been removed from your cart. Products and subscriptions can not be purchased at the same time.', 'woocommerce-subscriptions' ), 'notice' ); + + // Redirect to cart page to remove subscription & notify shopper + add_filter( 'add_to_cart_fragments', __CLASS__ . '::redirect_ajax_add_to_cart' ); + + } + + return $valid; + } + + /** + * Removes all subscription products from the shopping cart. + * + * @since 1.0 + */ + public static function remove_subscriptions_from_cart() { + + foreach ( WC()->cart->cart_contents as $cart_item_key => $cart_item ) { + if ( WC_Subscriptions_Product::is_subscription( $cart_item['product_id'] ) ) { + WC()->cart->set_quantity( $cart_item_key, 0 ); + } + } + } + + /** + * For a smoother sign up process, tell WooCommerce to redirect the shopper immediately to + * the checkout page after she clicks the "Sign Up Now" button + * + * Only enabled if multiple checkout is not enabled. + * + * @param string $url The cart redirect $url WooCommerce determined. + * @since 1.0 + */ + public static function add_to_cart_redirect( $url ) { + + // If product is of the subscription type + if ( isset( $_REQUEST['add-to-cart'] ) && is_numeric( $_REQUEST['add-to-cart'] ) && WC_Subscriptions_Product::is_subscription( (int) $_REQUEST['add-to-cart'] ) ) { + + // Redirect to checkout if mixed checkout is disabled + if ( 'yes' != get_option( WC_Subscriptions_Admin::$option_prefix . '_multiple_purchase', 'no' ) ) { + + wc_clear_notices(); + + $url = WC()->cart->get_checkout_url(); + + // Redirect to the same page (if the customer wouldn't be redirected to the cart) to ensure the cart widget loads correctly + } elseif ( 'yes' != get_option( 'woocommerce_cart_redirect_after_add' ) ) { + + $url = remove_query_arg( 'add-to-cart' ); + + } + } + + return $url; + } + + /** + * Override the WooCommerce "Place Order" text with "Sign Up Now" + * + * @since 1.0 + */ + public static function order_button_text( $button_text ) { + global $product; + + if ( WC_Subscriptions_Cart::cart_contains_subscription() ) { + $button_text = get_option( WC_Subscriptions_Admin::$option_prefix . '_order_button_text', __( 'Sign Up Now', 'woocommerce-subscriptions' ) ); + } + + return $button_text; + } + + /** + * Load the subscription add_to_cart template. + * + * Use the same cart template for subscription as that which is used for simple products. Reduce code duplication + * and is made possible by the friendly actions & filters found through WC. + * + * Not using a custom template both prevents code duplication and helps future proof this extension from core changes. + * + * @since 1.0 + */ + public static function subscription_add_to_cart() { + wc_get_template( 'single-product/add-to-cart/subscription.php', array(), '', plugin_dir_path( __FILE__ ) . 'templates/' ); + } + + /** + * Load the variable subscription add_to_cart template + * + * Use a very similar cart template as that of a variable product with added functionality. + * + * @since 2.0.9 + */ + public static function variable_subscription_add_to_cart() { + global $product; + + // Enqueue variation scripts + wp_enqueue_script( 'wc-add-to-cart-variation' ); + + // Get Available variations? + $get_variations = sizeof( $product->get_children() ) <= apply_filters( 'woocommerce_ajax_variation_threshold', 30, $product ); + + // Load the template + wc_get_template( 'single-product/add-to-cart/variable-subscription.php', array( + 'available_variations' => $get_variations ? $product->get_available_variations() : false, + 'attributes' => $product->get_variation_attributes(), + 'selected_attributes' => $product->get_variation_default_attributes(), + ), '', plugin_dir_path( __FILE__ ) . 'templates/' ); + } + + /** + * Compatibility with WooCommerce On One Page Checkout. + * + * Use OPC's simple add to cart template for simple subscription products (to ensure data attributes required by OPC are added). + * + * Variable subscription products will be handled automatically because they identify as "variable" in response to is_type() method calls, + * which OPC uses. + * + * @since 1.5.16 + */ + public static function wcopc_subscription_add_to_cart() { + global $product; + wc_get_template( 'checkout/add-to-cart/simple.php', array( 'product' => $product ), '', PP_One_Page_Checkout::$template_path ); + } + + /** + * Takes a number and returns the number with its relevant suffix appended, eg. for 2, the function returns 2nd + * + * @since 1.0 + */ + public static function append_numeral_suffix( $number ) { + + // Handle teens: if the tens digit of a number is 1, then write "th" after the number. For example: 11th, 13th, 19th, 112th, 9311th. http://en.wikipedia.org/wiki/English_numerals + if ( strlen( $number ) > 1 && 1 == substr( $number, -2, 1 ) ) { + // translators: placeholder is a number, this is for the teens + $number_string = sprintf( __( '%sth', 'woocommerce-subscriptions' ), $number ); + } else { // Append relevant suffix + switch ( substr( $number, -1 ) ) { + case 1: + // translators: placeholder is a number, numbers ending in 1 + $number_string = sprintf( __( '%sst', 'woocommerce-subscriptions' ), $number ); + break; + case 2: + // translators: placeholder is a number, numbers ending in 2 + $number_string = sprintf( __( '%snd', 'woocommerce-subscriptions' ), $number ); + break; + case 3: + // translators: placeholder is a number, numbers ending in 3 + $number_string = sprintf( __( '%srd', 'woocommerce-subscriptions' ), $number ); + break; + default: + // translators: placeholder is a number, numbers ending in 4-9, 0 + $number_string = sprintf( __( '%sth', 'woocommerce-subscriptions' ), $number ); + break; + } + } + + return apply_filters( 'woocommerce_numeral_suffix', $number_string, $number ); + } + + + /* + * Plugin House Keeping + */ + + /** + * Called when WooCommerce is inactive to display an inactive notice. + * + * @since 1.2 + */ + public static function woocommerce_inactive_notice() { + if ( current_user_can( 'activate_plugins' ) ) : + if ( ! is_woocommerce_active() ) : ?> +
    +

    'install-plugin', 'plugin' => 'woocommerce' ), admin_url( 'update.php' ) ), 'install-plugin_woocommerce' ); + + // translators: 1$-2$: opening and closing tags, 3$-4$: link tags, takes to woocommerce plugin on wp.org, 5$-6$: opening and closing link tags, leads to plugins.php in admin + printf( esc_html__( '%1$sWooCommerce Subscriptions is inactive.%2$s The %3$sWooCommerce plugin%4$s must be active for WooCommerce Subscriptions to work. Please %5$sinstall & activate WooCommerce »%6$s', 'woocommerce-subscriptions' ), '', '', '', '', '', '' ); ?> +

    +
    + +
    +

    tags, 3$-4$: opening and closing link tags, leads to plugin admin + printf( esc_html__( '%1$sWooCommerce Subscriptions is inactive.%2$s This version of Subscriptions requires WooCommerce 2.3 or newer. Please %3$supdate WooCommerce to version 2.3 or newer »%4$s', 'woocommerce-subscriptions' ), '', '', '', '' ); ?> +

    +
    + + version, '2.3', '<' ) && current_user_can( 'install_plugins' ) ) { ?> +
    +

    tags, 3$-4$: opening and closing link tags, leads to plugin admin + printf( esc_html__( '%1$sYou have an out-of-date version of WooCommerce installed%2$s. WooCommerce Subscriptions no longer supports versions of WooCommerce prior to 2.3. Please %3$supgrade WooCommerce to version 2.3 or newer%4$s to avoid issues.', 'woocommerce-subscriptions' ), '', '', '', '' ); ?> +

    +
    + + +
    +

    tags, 3$-4$: opening and closing link tags. Leads to duplicate site article on docs + printf( esc_html__( 'It looks like this site has moved or is a duplicate site. %1$sWooCommerce Subscriptions%2$s has disabled automatic payments and subscription related emails on this site to prevent duplicate payments from a staging or test environment. %3$sLearn more »%4$s.', 'woocommerce-subscriptions' ), '', '', '', '' ); ?>

    +
    + + +
    +
    + 'subscription_details'. + * + * The $args param is based on the parameter of the same name used by the core WordPress @see get_posts() function. + * It can be used to choose which subscriptions should be returned by the function, how many subscriptions should be returned + * and in what order those subscriptions should be returned. + * + * @param array $args A set of name value pairs to determine the return value. + * 'subscriptions_per_page' The number of subscriptions to return. Set to -1 for unlimited. Default 10. + * 'offset' An optional number of subscription to displace or pass over. Default 0. + * 'orderby' The field which the subscriptions should be ordered by. Can be 'start_date', 'expiry_date', 'end_date', 'status', 'name' or 'order_id'. Defaults to 'start_date'. + * 'order' The order of the values returned. Can be 'ASC' or 'DESC'. Defaults to 'DESC' + * 'customer_id' The user ID of a customer on the site. + * 'product_id' The post ID of a WC_Product_Subscription, WC_Product_Variable_Subscription or WC_Product_Subscription_Variation object + * 'subscription_status' Any valid subscription status. Can be 'any', 'active', 'cancelled', 'suspended', 'expired', 'pending' or 'trash'. Defaults to 'any'. + * @return array Subscription details in 'subscription_key' => 'subscription_details' form. + * @since 1.4 + */ + public static function get_subscriptions( $args = array() ) { + + if ( isset( $args['orderby'] ) ) { + // Although most of these weren't public orderby values, they were used internally so may have been used by developers + switch ( $args['orderby'] ) { + case '_subscription_status' : + _deprecated_argument( __METHOD__, '2.0', 'The "_subscription_status" orderby value is deprecated. Use "status" instead.' ); + $args['orderby'] = 'status'; + break; + case '_subscription_start_date' : + _deprecated_argument( __METHOD__, '2.0', 'The "_subscription_start_date" orderby value is deprecated. Use "start_date" instead.' ); + $args['orderby'] = 'start_date'; + break; + case 'expiry_date' : + case '_subscription_expiry_date' : + case '_subscription_end_date' : + _deprecated_argument( __METHOD__, '2.0', 'The expiry date orderby value is deprecated. Use "end_date" instead.' ); + $args['orderby'] = 'end_date'; + break; + case 'trial_expiry_date' : + case '_subscription_trial_expiry_date' : + _deprecated_argument( __METHOD__, '2.0', 'The trial expiry date orderby value is deprecated. Use "trial_end_date" instead.' ); + $args['orderby'] = 'trial_end_date'; + break; + case 'name' : + _deprecated_argument( __METHOD__, '2.0', 'The "name" orderby value is deprecated - subscriptions no longer have just one name as they may contain multiple items.' ); + break; + } + } + + _deprecated_function( __METHOD__, '2.0', 'wcs_get_subscriptions( $args )' ); + + $subscriptions = wcs_get_subscriptions( $args ); + + $subscriptions_in_deprecated_structure = array(); + + // Get the subscriptions in the backward compatible structure + foreach ( $subscriptions as $subscription ) { + $subscriptions_in_deprecated_structure[ wcs_get_old_subscription_key( $subscription ) ] = wcs_get_subscription_in_deprecated_structure( $subscription ); + } + + return apply_filters( 'woocommerce_get_subscriptions', $subscriptions_in_deprecated_structure, $args ); + } + + /** + * Returns the longest possible time period + * + * @since 1.3 + */ + public static function get_longest_period( $current_period, $new_period ) { + + if ( empty( $current_period ) || 'year' == $new_period ) { + $longest_period = $new_period; + } elseif ( 'month' === $new_period && in_array( $current_period, array( 'week', 'day' ) ) ) { + $longest_period = $new_period; + } elseif ( 'week' === $new_period && 'day' === $current_period ) { + $longest_period = $new_period; + } else { + $longest_period = $current_period; + } + + return $longest_period; + } + + /** + * Returns the shortest possible time period + * + * @since 1.3.7 + */ + public static function get_shortest_period( $current_period, $new_period ) { + + if ( empty( $current_period ) || 'day' == $new_period ) { + $shortest_period = $new_period; + } elseif ( 'week' === $new_period && in_array( $current_period, array( 'month', 'year' ) ) ) { + $shortest_period = $new_period; + } elseif ( 'month' === $new_period && 'year' === $current_period ) { + $shortest_period = $new_period; + } else { + $shortest_period = $current_period; + } + + return $shortest_period; + } + + /** + * Returns Subscriptions record of the site URL for this site + * + * @since 1.3.8 + */ + public static function get_site_url( $blog_id = null, $path = '', $scheme = null ) { + if ( empty( $blog_id ) || ! is_multisite() ) { + $url = get_option( 'wc_subscriptions_siteurl' ); + } else { + switch_to_blog( $blog_id ); + $url = get_option( 'wc_subscriptions_siteurl' ); + restore_current_blog(); + } + + // Remove the prefix used to prevent the site URL being updated on WP Engine + $url = str_replace( '_[wc_subscriptions_siteurl]_', '', $url ); + + $url = set_url_scheme( $url, $scheme ); + + if ( ! empty( $path ) && is_string( $path ) && strpos( $path, '..' ) === false ) { + $url .= '/' . ltrim( $path, '/' ); + } + + return apply_filters( 'wc_subscriptions_site_url', $url, $path, $scheme, $blog_id ); + } + + /** + * Checks if the WordPress site URL is the same as the URL for the site subscriptions normally + * runs on. Useful for checking if automatic payments should be processed. + * + * @since 1.3.8 + */ + public static function is_duplicate_site() { + + $is_duplicate = ( get_site_url() !== self::get_site_url() ) ? true : false; + + return apply_filters( 'woocommerce_subscriptions_is_duplicate_site', $is_duplicate ); + } + + + /** + * Include Docs & Settings links on the Plugins administration screen + * + * @param mixed $links + * @since 1.4 + */ + public static function action_links( $links ) { + + $plugin_links = array( + '' . __( 'Settings', 'woocommerce-subscriptions' ) . '', + '' . _x( 'Docs', 'short for documents', 'woocommerce-subscriptions' ) . '', + '' . __( 'Support', 'woocommerce-subscriptions' ) . '', + ); + + return array_merge( $plugin_links, $links ); + } + + /** + * Creates a URL based on the current site's URL that can be used to prevent duplicate payments from staging sites. + * + * The URL can not simply be the site URL, e.g. http://example.com, because WP Engine replaces all instances of the site URL in the database + * when creating a staging site. As a result, we obfuscate the URL by inserting '_[wc_subscriptions_siteurl]_' into the middle of it. + * + * Why not just use a hash? Because keeping the URL in the value allows for viewing and editing the URL directly in the database. + * + * @param mixed $links + * @since 1.4.2 + */ + public static function get_current_sites_duplicate_lock() { + + $site_url = get_option( 'siteurl' ); + + return substr_replace( $site_url, '_[wc_subscriptions_siteurl]_', strlen( $site_url ) / 2, 0 ); + } + + /** + * Sets a flag in the database to record the site's url. This then checked to determine if we are on a duplicate + * site or the original/main site, uses @see self::get_current_sites_duplicate_lock(); + * + * @param mixed $links + * @since 1.4.2 + */ + public static function set_duplicate_site_url_lock() { + update_option( 'wc_subscriptions_siteurl', self::get_current_sites_duplicate_lock() ); + } + + /** + * Check if the installed version of WooCommerce is older than a specified version. + * + * @since 1.5.29 + */ + public static function is_woocommerce_pre( $version ) { + + if ( ! defined( 'WC_VERSION' ) || version_compare( WC_VERSION, $version, '<' ) ) { + $woocommerce_is_pre_version = true; + } else { + $woocommerce_is_pre_version = false; + } + + return $woocommerce_is_pre_version; + } + + /** + * Add WooCommerce error or success notice regardless of the version of WooCommerce running. + * + * @param string $message The text to display in the notice. + * @param string $notice_type The singular name of the notice type - either error, success or notice. [optional] + * @since version 1.4.5 + */ + public static function add_notice( $message, $notice_type = 'success' ) { + wc_add_notice( $message, $notice_type ); + } + + /** + * Print WooCommerce messages regardless of the version of WooCommerce running. + * + * @since version 1.4.5 + */ + public static function print_notices() { + wc_print_notices(); + } + + /** + * Renewals use a lot more memory on WordPress multisite (10-15mb instead of 0.1-1mb) so + * we need to reduce the number of renewals run in each request. + * + * @since version 1.5 + */ + public static function action_scheduler_multisite_batch_size( $batch_size ) { + + if ( is_multisite() ) { + $batch_size = 10; + } + + return $batch_size; + } + + /** + * Include the upgrade notice that will fire when 2.0 is released. + * + * @param array $plugin_data information about the plugin + * @param array $r response from the server about the new version + */ + public static function update_notice( $plugin_data, $r ) { + + // Bail if the update notice is not relevant (new version is not yet 2.0 or we're already on 2.0) + if ( version_compare( '2.0.0', $plugin_data['new_version'], '>' ) || version_compare( '2.0.0', $plugin_data['Version'], '<=' ) ) { + return; + } + + $update_notice = '
    '; + // translators: placeholders are opening and closing tags. Leads to docs on version 2 + $update_notice .= sprintf( __( 'Warning! Version 2.0 is a major update to the WooCommerce Subscriptions extension. Before updating, please create a backup, update all WooCommerce extensions and test all plugins, custom code and payment gateways with version 2.0 on a staging site. %sLearn more about the changes in version 2.0 »%s', 'woocommerce-subscriptions' ), '', '' ); + $update_notice .= '
    '; + + echo wp_kses_post( $update_notice ); + } + + /** + * Send notice to store admins if they have previously updated Subscriptions to 2.0 and back to v1.5.n. + * + * @since 2.0 + */ + public static function show_downgrade_notice() { + if ( version_compare( get_option( WC_Subscriptions_Admin::$option_prefix . '_active_version', '0' ), self::$version, '>' ) ) { + + echo '
    '; + echo sprintf( esc_html__( 'Warning! You are running version %s of WooCommerce Subscriptions plugin code but your database has been upgraded to Subscriptions version 2.0. This will cause major problems on your store.', 'woocommerce-subscriptions' ), esc_html( self::$version ) ) . '
    '; + echo sprintf( esc_html__( 'Please upgrade the WooCommerce Subscriptions plugin to version 2.0 or newer immediately. If you need assistance, after upgrading to Subscriptions v2.0, please %sopen a support ticket%s.', 'woocommerce-subscriptions' ), '', '' ); + echo '
    '; + + } + } + + /* Deprecated Functions */ + + /** + * Was called when a plugin is activated using official register_activation_hook() API + * + * Upgrade routine is now in @see maybe_activate_woocommerce_subscriptions() + * + * @since 1.0 + */ + public static function activate_woocommerce_subscriptions() { + _deprecated_function( __METHOD__, '1.1', __CLASS__ . '::maybe_activate_woocommerce_subscriptions()' ); + } + + /** + * Override the WooCommerce "Add to Cart" text with "Sign Up Now" + * + * @since 1.0 + * @deprecated 1.5 + */ + public static function add_to_cart_text( $button_text, $product_type = '' ) { + global $product; + + _deprecated_function( __METHOD__, '1.1', 'WC_Product::add_to_cart_text()' ); + + if ( WC_Subscriptions_Product::is_subscription( $product ) || in_array( $product_type, array( 'subscription', 'subscription-variation' ) ) ) { + $button_text = get_option( WC_Subscriptions_Admin::$option_prefix . '_add_to_cart_button_text', __( 'Sign Up Now', 'woocommerce-subscriptions' ) ); + } + + return $button_text; + } + + /** + * Subscriptions are individual items so override the WC_Product is_sold_individually function + * to reflect this. + * + * @since 1.0 + * @deprecated 1.5 + */ + public static function is_sold_individually( $is_individual, $product ) { + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, '1.1', 'WC_Product::is_sold_individually()' ); + return $is_individual; + } + + /** + * Workaround the last day of month quirk in PHP's strtotime function. + * + * @since 1.2.5 + * @deprecated 2.0 + */ + public static function add_months( $from_timestamp, $months_to_add ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_add_months()' ); + return wcs_add_months( $from_timestamp, $months_to_add ); + } + + /** + * A flag to indicate whether the current site has roughly more than 3000 subscriptions. Used to disable + * features on the Manage Subscriptions list table that do not scale well (yet). + * + * Deprecated since querying the new subscription post type is a lot more efficient and no longer puts strain on the database + * + * @since 1.4.4 + * @deprecated 2.0 + */ + public static function is_large_site() { + _deprecated_function( __METHOD__, '2.0' ); + return apply_filters( 'woocommerce_subscriptions_is_large_site', false ); + } + + /** + * Returns the total number of Subscriptions on the site. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function get_total_subscription_count() { + _deprecated_function( __METHOD__, '2.0' ); + + if ( null === self::$total_subscription_count ) { + self::$total_subscription_count = self::get_subscription_count(); + } + + return apply_filters( 'woocommerce_get_total_subscription_count', self::$total_subscription_count ); + } + + /** + * Returns an associative array with the structure 'status' => 'count' for all subscriptions on the site + * and includes an "all" status, representing all subscriptions. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function get_subscription_status_counts() { + _deprecated_function( __METHOD__, '2.0' ); + + $results = wp_count_posts( 'shop_subscription' ); + $count = array(); + + foreach ( $results as $status => $count ) { + + if ( in_array( $status, array_keys( wcs_get_subscription_statuses() ) ) || in_array( $status, array( 'trash', 'draft' ) ) ) { + $counts[ $status ] = $count; + } + } + + // Order with 'all' at the beginning, then alphabetically + ksort( $counts ); + $counts = array( 'all' => array_sum( $counts ) ) + $counts; + + return apply_filters( 'woocommerce_subscription_status_counts', $counts ); + } + + /** + * Takes an array of filter params and returns the number of subscriptions which match those params. + * + * @since 1.4 + * @deprecated 2.0 + */ + public static function get_subscription_count( $args = array() ) { + _deprecated_function( __METHOD__, '2.0' ); + + $args['subscriptions_per_page'] = -1; + $subscription_count = 0; + + if ( ( ! isset( $args['subscription_status'] ) || in_array( $args['subscription_status'], array( 'all', 'any' ) ) ) && ( isset( $args['include_trashed'] ) && true === $args['include_trashed'] ) ) { + + $args['subscription_status'] = 'trash'; + $subscription_count += count( wcs_get_subscriptions( $args ) ); + $args['subscription_status'] = 'any'; + } + + $subscription_count += count( wcs_get_subscriptions( $args ) ); + + return apply_filters( 'woocommerce_get_subscription_count', $subscription_count, $args ); + } + + /** + * Check if the installed version of WooCommerce is 2.3 or older. + * + * @since 1.5.17 + */ + public static function is_woocommerce_pre_2_3() { + _deprecated_function( __METHOD__, '1.5.29', __CLASS__ . '::is_woocommerce_pre( "2.3" )' ); + return self::is_woocommerce_pre( '2.3' ); + } + + /** + * Check if the installed version of WooCommerce is 2.2 or older. + * + * @since 1.5.10 + */ + public static function is_woocommerce_pre_2_2() { + _deprecated_function( __METHOD__, '1.5.29', __CLASS__ . '::is_woocommerce_pre( "2.2" )' ); + return self::is_woocommerce_pre( '2.2' ); + } + + /** + * Check if the installed version of WooCommerce is 2.1 or older. + * + * Only for use when we need to check version. If the code in question relys on a specific + * WC2.1 only function or class, then it's better to check that function or class exists rather + * than using this more generic check. + * + * @since 1.4.5 + * @deprecated 2.0 Removing support for WC before 2.3.0 + */ + public static function is_woocommerce_pre_2_1() { + _deprecated_function( __METHOD__, '1.5.29', __CLASS__ . '::is_woocommerce_pre( "2.1" )' ); + return self::is_woocommerce_pre( '2.1' ); + } + + /** + * which was called @see woocommerce_format_total() prior to WooCommerce 2.1. + * + * Deprecated since we no longer need to support the workaround required for WC versions < 2.1 + * + * @since version 1.4.6 + * @deprecated 2.0 + */ + public static function format_total( $number ) { + _deprecated_function( __METHOD__, '2.0', 'wc_format_decimal()' ); + return wc_format_decimal( $number ); + } +} + +WC_Subscriptions::init();