diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index c04bc95aa8..e0b378bdc6 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -2,6 +2,7 @@ { public class BillingSettings { + public virtual string JobsKey { get; set; } public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret { get; set; } public virtual string BraintreeWebhookKey { get; set; } diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs new file mode 100644 index 0000000000..44da209f93 --- /dev/null +++ b/src/Billing/Controllers/JobsController.cs @@ -0,0 +1,57 @@ +using Bit.Core; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Bit.Billing.Controllers +{ + [Route("jobs")] + public class JobsController : Controller + { + private readonly BillingSettings _billingSettings; + private readonly GlobalSettings _globalSettings; + private readonly IUserRepository _userRepository; + private readonly IMailService _mailService; + + public JobsController( + IOptions billingSettings, + GlobalSettings globalSettings, + IUserRepository userRepository, + IMailService mailService) + { + _billingSettings = billingSettings?.Value; + _globalSettings = globalSettings; + _userRepository = userRepository; + _mailService = mailService; + } + + [HttpPost("premium-renewal-reminders")] + public async Task GetPremiumRenewalReminders([FromQuery] string key) + { + if(key != _billingSettings.JobsKey) + { + return new BadRequestResult(); + } + + var users = await _userRepository.GetManyByPremiumRenewalAsync(); + foreach(var user in users) + { + var paymentService = user.GetPaymentService(_globalSettings); + var upcomingInvoice = await paymentService.GetUpcomingInvoiceAsync(user); + if(upcomingInvoice?.Date != null) + { + var items = new List { "1 × Premium Membership (Annually)" }; + await _mailService.SendInvoiceUpcomingAsync(user.Email, upcomingInvoice.Amount, + upcomingInvoice.Date.Value, items, false); + } + await _userRepository.UpdateRenewalReminderDateAsync(user.Id, DateTime.UtcNow); + } + + return new OkResult(); + } + } +} diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 9a9bf95680..787cec2ede 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -46,6 +46,7 @@ } }, "billingSettings": { + "jobsKey": "SECRET", "stripeWebhookKey": "SECRET", "stripeWebhookSecret": "SECRET", "braintreeWebhookKey": "SECRET" diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 4d0505d6d0..975f3edbb3 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -10,8 +10,10 @@ namespace Bit.Core.Repositories Task GetByEmailAsync(string email); Task> SearchAsync(string email, int skip, int take); Task> GetManyByPremiumAsync(bool premium); + Task> GetManyByPremiumRenewalAsync(); Task GetPublicKeyAsync(Guid id); Task GetAccountRevisionDateAsync(Guid id); Task UpdateStorageAsync(Guid id); + Task UpdateRenewalReminderDateAsync(Guid id, DateTime renewalReminderDate); } } diff --git a/src/Core/Repositories/SqlServer/UserRepository.cs b/src/Core/Repositories/SqlServer/UserRepository.cs index 210a1f1038..bea608bcc0 100644 --- a/src/Core/Repositories/SqlServer/UserRepository.cs +++ b/src/Core/Repositories/SqlServer/UserRepository.cs @@ -63,6 +63,18 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task> GetManyByPremiumRenewalAsync() + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[User_ReadByPremiumRenewal]", + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task GetPublicKeyAsync(Guid id) { using(var connection = new SqlConnection(ConnectionString)) @@ -117,5 +129,16 @@ namespace Bit.Core.Repositories.SqlServer commandTimeout: 180); } } + + public async Task UpdateRenewalReminderDateAsync(Guid id, DateTime renewalReminderDate) + { + using(var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + $"[{Schema}].[User_UpdateRenewalReminderDate]", + new { Id = id, RenewalReminderDate = renewalReminderDate }, + commandType: CommandType.StoredProcedure); + } + } } } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 6a939ae32f..41da719bf9 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -12,6 +12,7 @@ namespace Bit.Core.Services Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false); Task ReinstateSubscriptionAsync(ISubscriber subscriber); Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken); + Task GetUpcomingInvoiceAsync(ISubscriber subscriber); Task GetBillingAsync(ISubscriber subscriber); } } diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs index b17383913e..78d6fc601f 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -156,6 +156,24 @@ namespace Bit.Core.Services } } + public async Task GetUpcomingInvoiceAsync(ISubscriber subscriber) + { + if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) + { + var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId); + if(sub != null) + { + var cancelAtEndDate = !sub.NeverExpires.GetValueOrDefault(); + var cancelled = sub.Status == SubscriptionStatus.CANCELED; + if(!cancelled && !cancelAtEndDate && sub.NextBillingDate.HasValue) + { + return new BillingInfo.BillingInvoice(sub); + } + } + } + return null; + } + public async Task GetBillingAsync(ISubscriber subscriber) { var billingInfo = new BillingInfo(); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 12d18d96ce..ddf6c04f18 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -320,6 +320,32 @@ namespace Bit.Core.Services return updatedSubscriber; } + public async Task GetUpcomingInvoiceAsync(ISubscriber subscriber) + { + if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) + { + var subscriptionService = new StripeSubscriptionService(); + var invoiceService = new StripeInvoiceService(); + var sub = await subscriptionService.GetAsync(subscriber.GatewaySubscriptionId); + if(sub != null) + { + if(!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + { + try + { + var upcomingInvoice = await invoiceService.UpcomingAsync(subscriber.GatewayCustomerId); + if(upcomingInvoice != null) + { + return new BillingInfo.BillingInvoice(upcomingInvoice); + } + } + catch(StripeException) { } + } + } + } + return null; + } + public async Task GetBillingAsync(ISubscriber subscriber) { var billingInfo = new BillingInfo(); diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index de483ccba5..35379083b3 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -226,5 +226,7 @@ + + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_ReadByPremiumRenewal.sql b/src/Sql/dbo/Stored Procedures/User_ReadByPremiumRenewal.sql new file mode 100644 index 0000000000..d281a68046 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadByPremiumRenewal.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[User_ReadByPremiumRenewal] +AS +BEGIN + SET NOCOUNT ON + + DECLARE @WindowRef DATETIME2(7) = GETUTCDATE() + DECLARE @WindowStart DATETIME2(7) = DATEADD (day, -15, @WindowRef) + DECLARE @WindowEnd DATETIME2(7) = DATEADD (day, 15, @WindowRef) + + SELECT + * + FROM + [dbo].[UserView] + WHERE + [Premium] = 1 + AND [PremiumExpirationDate] >= @WindowRef + AND [PremiumExpirationDate] < @WindowEnd + AND ( + [RenewalReminderDate] IS NULL + OR [RenewalReminderDate] < @WindowStart + ) + AND [Gateway] = 1 -- Braintree +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_UpdateRenewalReminderDate.sql b/src/Sql/dbo/Stored Procedures/User_UpdateRenewalReminderDate.sql new file mode 100644 index 0000000000..e9105b49b3 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_UpdateRenewalReminderDate.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[User_UpdateRenewalReminderDate] + @Id UNIQUEIDENTIFIER, + @RenewalReminderDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [RenewalReminderDate] = @RenewalReminderDate + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql b/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql index 7d44bb5e81..21ae2ee8c6 100644 --- a/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql +++ b/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql @@ -7,6 +7,59 @@ BEGIN END GO +IF OBJECT_ID('[dbo].[User_UpdateRenewalReminderDate]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_UpdateRenewalReminderDate] +END +GO + +CREATE PROCEDURE [dbo].[User_UpdateRenewalReminderDate] + @Id UNIQUEIDENTIFIER, + @RenewalReminderDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [RenewalReminderDate] = @RenewalReminderDate + WHERE + [Id] = @Id +END +GO + +IF OBJECT_ID('[dbo].[User_ReadByPremiumRenewal]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_ReadByPremiumRenewal] +END +GO + +CREATE PROCEDURE [dbo].[User_ReadByPremiumRenewal] +AS +BEGIN + SET NOCOUNT ON + + DECLARE @WindowRef DATETIME2(7) = GETUTCDATE() + DECLARE @WindowStart DATETIME2(7) = DATEADD (day, -15, @WindowRef) + DECLARE @WindowEnd DATETIME2(7) = DATEADD (day, 15, @WindowRef) + + SELECT + * + FROM + [dbo].[UserView] + WHERE + [Premium] = 1 + AND [PremiumExpirationDate] >= @WindowRef + AND [PremiumExpirationDate] < @WindowEnd + AND ( + [RenewalReminderDate] IS NULL + OR [RenewalReminderDate] < @WindowStart + ) + AND [Gateway] = 1 -- Braintree +END +GO + IF OBJECT_ID('[dbo].[Collection_ReadByUserId]') IS NOT NULL BEGIN DROP PROCEDURE [dbo].[Collection_ReadByUserId]