Browse Source

Implemented loading of pricing/subscription info from stripe

feature/billing
Teknikode 11 months ago
parent
commit
63ef371a4a
  1. 23
      BillingCore/BillingFactory.cs
  2. 15
      BillingCore/BillingService.cs
  3. 17
      BillingCore/Models/Interval.cs
  4. 2
      BillingCore/Models/IntervalType.cs
  5. 21
      BillingCore/Models/Price.cs
  6. 13
      BillingCore/Models/PriceType.cs
  7. 10
      BillingCore/Models/Product.cs
  8. 16
      BillingCore/Models/Subscription.cs
  9. 19
      BillingCore/Models/SubscriptionStatus.cs
  10. 198
      BillingCore/StripeService.cs
  11. 16
      BillingCore/Subscription.cs
  12. 17
      BillingCore/UserSubscription.cs
  13. 5
      Configuration/BillingConfig.cs
  14. 13
      Configuration/BillingType.cs
  15. 2
      Teknik/App_Data/endpointMappings.json
  16. 156
      Teknik/Areas/Billing/Controllers/BillingController.cs
  17. 9
      Teknik/Areas/Billing/ViewModels/SubscriptionViewModel.cs
  18. 0
      Teknik/Areas/Billing/Views/Billing/ViewSubscription.cshtml
  19. 4
      Teknik/Areas/Billing/Views/Billing/ViewSubscriptions.cshtml
  20. 1
      Teknik/Teknik.csproj

23
BillingCore/BillingFactory.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Teknik.Configuration;
namespace Teknik.BillingCore
{
public static class BillingFactory
{
public static BillingService GetStorageService(BillingConfig config)
{
switch (config.Type)
{
case BillingType.Stripe:
return new StripeService(config);
default:
return null;
}
}
}
}

15
BillingCore/BillingService.cs

@ -3,6 +3,7 @@ using System.Collections.Generic; @@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Teknik.BillingCore.Models;
using Teknik.Configuration;
namespace Teknik.BillingCore
@ -16,14 +17,20 @@ namespace Teknik.BillingCore @@ -16,14 +17,20 @@ namespace Teknik.BillingCore
Config = billingConfig;
}
public abstract object GetCustomer(string id);
public abstract bool CreateCustomer(string email);
public abstract Tuple<bool, string, string> CreateSubscription(string customerId, string priceId);
public abstract bool EditSubscription();
public abstract List<Product> GetProductList();
public abstract Product GetProduct(string productId);
public abstract Subscription GetSubscription(string subscriptionId);
public abstract List<Price> GetPriceList(string productId);
public abstract Price GetPrice(string priceId);
public abstract bool RemoveSubscription();
public abstract List<Subscription> GetSubscriptionList(string customerId);
public abstract Subscription GetSubscription(string subscriptionId);
public abstract Tuple<bool, string, string> CreateSubscription(string customerId, string priceId);
public abstract bool EditSubscription(Subscription subscription);
public abstract bool RemoveSubscription(string subscriptionId);
public abstract void SyncSubscriptions();
}

17
BillingCore/Models/Interval.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore.Models
{
public enum Interval
{
Once,
Day,
Week,
Month,
Year
}
}

2
BillingCore/IntervalType.cs → BillingCore/Models/IntervalType.cs

@ -4,7 +4,7 @@ using System.Linq; @@ -4,7 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore
namespace Teknik.BillingCore.Models
{
public enum IntervalType
{

21
BillingCore/Models/Price.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore.Models
{
public class Price
{
public string ProductId { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public bool Recommended { get; set; }
public decimal? Amount { get; set; }
public string Currency { get; set; }
public long Storage { get; set; }
public Interval Interval { get; set; }
}
}

13
BillingCore/Models/PriceType.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore.Models
{
public enum PriceType
{
Recurring
}
}

10
BillingCore/Product.cs → BillingCore/Models/Product.cs

@ -4,12 +4,18 @@ using System.Linq; @@ -4,12 +4,18 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore
namespace Teknik.BillingCore.Models
{
public class Product
{
public int ProductId { get; set; }
public string ProductId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<Price> Prices { get; set; }
public Product()
{
Prices = new List<Price>();
}
}
}

16
BillingCore/Models/Subscription.cs

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore.Models
{
public class Subscription
{
public string Id { get; set; }
public string CustomerId { get; set; }
public SubscriptionStatus Status { get; set; }
public List<Price> Prices { get; set; }
}
}

19
BillingCore/Models/SubscriptionStatus.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore.Models
{
public enum SubscriptionStatus
{
Incomplete,
IncompleteExpired,
Trialing,
Active,
PastDue,
Canceled,
Unpaid
}
}

198
BillingCore/StripeService.cs

@ -4,6 +4,7 @@ using System.Collections.Generic; @@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Teknik.BillingCore.Models;
using Teknik.Configuration;
namespace Teknik.BillingCore
@ -11,7 +12,15 @@ namespace Teknik.BillingCore @@ -11,7 +12,15 @@ namespace Teknik.BillingCore
public class StripeService : BillingService
{
public StripeService(BillingConfig config) : base(config)
{ }
{
StripeConfiguration.ApiKey = config.StripeSecretApiKey;
}
public override object GetCustomer(string id)
{
var service = new CustomerService();
return service.Get(id);
}
public override bool CreateCustomer(string email)
{
@ -24,9 +33,97 @@ namespace Teknik.BillingCore @@ -24,9 +33,97 @@ namespace Teknik.BillingCore
return customer != null;
}
public override Subscription GetSubscription(string subscriptionId)
public override List<Models.Product> GetProductList()
{
throw new NotImplementedException();
var productList = new List<Models.Product>();
var productService = new ProductService();
var options = new ProductListOptions
{
Active = true
};
var products = productService.List(options);
foreach (var product in products)
{
productList.Add(ConvertProduct(product));
}
return productList;
}
public override Models.Product GetProduct(string productId)
{
var productService = new ProductService();
Stripe.Product product = productService.Get(productId);
if (product != null)
return ConvertProduct(product);
return null;
}
public override List<Models.Price> GetPriceList(string productId)
{
var foundPrices = new List<Models.Price>();
var options = new PriceListOptions
{
Active = true,
Product = productId
};
var priceService = new PriceService();
var priceList = priceService.List(options);
if (priceList != null)
{
foreach (var price in priceList)
{
foundPrices.Add(ConvertPrice(price));
}
}
return foundPrices;
}
public override Models.Price GetPrice(string priceId)
{
var priceService = new PriceService();
var price = priceService.Get(priceId);
if (price != null)
return ConvertPrice(price);
return null;
}
public override List<Models.Subscription> GetSubscriptionList(string customerId)
{
var subscriptionList = new List<Models.Subscription>();
if (!string.IsNullOrEmpty(customerId))
{
var options = new SubscriptionListOptions
{
Customer = customerId
};
var subService = new SubscriptionService();
var subs = subService.List(options);
if (subs != null)
{
foreach (var sub in subs)
{
subscriptionList.Add(ConvertSubscription(sub));
}
}
}
return subscriptionList;
}
public override Models.Subscription GetSubscription(string subscriptionId)
{
var subService = new SubscriptionService();
var sub = subService.Get(subscriptionId);
if (sub != null)
return ConvertSubscription(sub);
return null;
}
public override Tuple<bool, string, string> CreateSubscription(string customerId, string priceId)
@ -60,12 +157,12 @@ namespace Teknik.BillingCore @@ -60,12 +157,12 @@ namespace Teknik.BillingCore
}
}
public override bool EditSubscription()
public override bool EditSubscription(Models.Subscription subscription)
{
throw new NotImplementedException();
}
public override bool RemoveSubscription()
public override bool RemoveSubscription(string subscriptionId)
{
throw new NotImplementedException();
}
@ -75,10 +172,95 @@ namespace Teknik.BillingCore @@ -75,10 +172,95 @@ namespace Teknik.BillingCore
throw new NotImplementedException();
}
private Customer GetCustomer(string id)
private Models.Product ConvertProduct(Stripe.Product product)
{
var service = new CustomerService();
return service.Get(id);
return new Models.Product()
{
ProductId = product.Id,
Name = product.Name,
Description = product.Description,
Prices = GetPriceList(product.Id)
};
}
private Models.Price ConvertPrice(Stripe.Price price)
{
var interval = Interval.Once;
if (price.Type == "recurring")
{
switch (price.Recurring.Interval)
{
case "day":
interval = Interval.Day;
break;
case "week":
interval = Interval.Week;
break;
case "month":
interval = Interval.Month;
break;
case "year":
interval = Interval.Year;
break;
}
}
var convPrice = new Models.Price()
{
Id = price.Id,
ProductId = price.ProductId,
Name = price.Nickname,
Interval = interval,
Currency = price.Currency
};
if (price.UnitAmountDecimal != null)
convPrice.Amount = price.UnitAmountDecimal / 100;
if (price.Metadata.ContainsKey("storage"))
convPrice.Storage = long.Parse(price.Metadata["storage"]);
return convPrice;
}
private Models.Subscription ConvertSubscription(Stripe.Subscription subscription)
{
var status = SubscriptionStatus.Incomplete;
switch (subscription.Status)
{
case "active":
status = SubscriptionStatus.Active;
break;
case "past_due":
status = SubscriptionStatus.PastDue;
break;
case "unpaid":
status = SubscriptionStatus.Unpaid;
break;
case "canceled":
status = SubscriptionStatus.Canceled;
break;
case "incomplete":
status = SubscriptionStatus.Incomplete;
break;
case "incomplete_expired":
status = SubscriptionStatus.IncompleteExpired;
break;
case "trialing":
status = SubscriptionStatus.Trialing;
break;
}
var prices = new List<Models.Price>();
if (subscription.Items != null)
{
foreach (var item in subscription.Items)
{
prices.Add(ConvertPrice(item.Price));
}
}
return new Models.Subscription()
{
Id = subscription.Id,
CustomerId = subscription.CustomerId,
Status = status,
Prices = prices
};
}
}
}

16
BillingCore/Subscription.cs

@ -1,16 +0,0 @@ @@ -1,16 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore
{
public class Subscription
{
public int SubscriptionId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public double Amount { get; set; }
}
}

17
BillingCore/UserSubscription.cs

@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.BillingCore
{
public class UserSubscription
{
public int UserId { get; set; }
public int SubscriptionId { get; set; }
public Subscription Subscription { get; set; }
public int ProductId { get; set; }
public Product Product { get; set; }
}
}

5
Configuration/BillingConfig.cs

@ -8,11 +8,16 @@ namespace Teknik.Configuration @@ -8,11 +8,16 @@ namespace Teknik.Configuration
{
public class BillingConfig
{
public BillingType Type { get; set; }
public string StripePublishApiKey { get; set; }
public string StripeSecretApiKey { get; set; }
public string UploadProductId { get; set; }
public string EmailProductId { get; set; }
public BillingConfig()
{
Type = BillingType.Stripe;
StripePublishApiKey = null;
StripeSecretApiKey = null;
}

13
Configuration/BillingType.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Teknik.Configuration
{
public enum BillingType
{
Stripe
}
}

2
Teknik/App_Data/endpointMappings.json

@ -1064,7 +1064,7 @@ @@ -1064,7 +1064,7 @@
"Area": "Billing",
"Defaults": {
"controller": "Billing",
"action": "Subscriptions"
"action": "ViewSubscriptions"
}
},
{

156
Teknik/Areas/Billing/Controllers/BillingController.cs

@ -6,6 +6,7 @@ using System.Collections.Generic; @@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Teknik.Areas.Billing.ViewModels;
using Teknik.BillingCore;
using Teknik.Configuration;
using Teknik.Controllers;
using Teknik.Data;
@ -26,75 +27,104 @@ namespace Teknik.Areas.Billing.Controllers @@ -26,75 +27,104 @@ namespace Teknik.Areas.Billing.Controllers
}
[AllowAnonymous]
public IActionResult Subscriptions()
public IActionResult ViewSubscriptions()
{
var subVM = new SubscriptionsViewModel();
// Get Upload Subscriptions
// Get Biling Service
var billingService = BillingFactory.GetStorageService(_config.BillingConfig);
// Get current subscriptions
string curSubId = null;
var curSubs = new Dictionary<string, List<string>>();
if (User.Identity.IsAuthenticated)
{
var currentSubs = billingService.GetSubscriptionList(User.Identity.Name);
foreach (var curSub in currentSubs)
{
foreach (var price in curSub.Prices)
{
if (!curSubs.ContainsKey(price.ProductId))
curSubs[price.ProductId] = new List<string>();
curSubs[price.ProductId].Add(price.Id);
}
}
}
// Show Free Subscription
subVM.UploadSubscriptions.Add(new SubscriptionViewModel()
{
CurrentPlan = true,
SubscriptionId = "upload_free",
SubscriptionName = "Basic Account",
CurrentPlan = curSubId == null,
SubscribeText = "Free",
SubscribeUrlMonthly = Url.SubRouteUrl("account", "User.Register"),
BaseStorage = 5368709120
});
subVM.UploadSubscriptions.Add(new SubscriptionViewModel()
{
Recommended = true,
SubscriptionId = "upload_10gb",
SubscriptionName = "Standalone 10 GB",
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "upload_10gb_monthly" }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "upload_10gb_yearly" }),
BaseStorage = 10737418240,
BasePriceMonthly = 0.99,
BasePriceYearly = 9.99
});
subVM.UploadSubscriptions.Add(new SubscriptionViewModel()
{
SubscriptionId = "upload_50gb",
SubscriptionName = "Standalone 50 GB",
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "upload_50gb_monthly" }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "upload_50gb_yearly" }),
BaseStorage = 53687091200,
BasePriceMonthly = 3.99,
BasePriceYearly = 39.99
});
subVM.UploadSubscriptions.Add(new SubscriptionViewModel()
{
SubscriptionId = "upload_100gb",
SubscriptionName = "Standalone 100 GB",
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "upload_100gb_monthly" }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "upload_100gb_yearly" }),
BaseStorage = 107374200000,
BasePriceMonthly = 5.99,
BasePriceYearly = 59.99
BaseStorage = _config.UploadConfig.MaxUploadSizeBasic
});
// Get Email Subscriptions
subVM.EmailSubscriptions.Add(new SubscriptionViewModel()
// Get Upload Prices
var curUploadSubs = new List<string>();
if (curSubs.ContainsKey(_config.BillingConfig.UploadProductId))
curUploadSubs = curSubs[_config.BillingConfig.UploadProductId];
var uploadProduct = billingService.GetProduct(_config.BillingConfig.UploadProductId);
if (uploadProduct != null)
{
Recommended = true,
SubscriptionId = "email_1gb",
SubscriptionName = "Basic Email",
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "email_1gb_monthly" }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "email_1gb_yearly" }),
BaseStorage = 1073741824,
BasePriceMonthly = 1.99,
BasePriceYearly = 19.99,
PanelOffset = "3"
});
subVM.EmailSubscriptions.Add(new SubscriptionViewModel()
bool handledFirst = false;
foreach (var priceGrp in uploadProduct.Prices.GroupBy(p => p.Storage).OrderBy(p => p.Key))
{
// Get Monthly prices
var priceMonth = priceGrp.FirstOrDefault(p => p.Interval == BillingCore.Models.Interval.Month);
// Get Yearly prices
var priceYear = priceGrp.FirstOrDefault(p => p.Interval == BillingCore.Models.Interval.Year);
var isCurrent = curUploadSubs.Exists(s => priceGrp.FirstOrDefault(p => p.ProductId == s) != null);
subVM.UploadSubscriptions.Add(new SubscriptionViewModel()
{
Recommended = !handledFirst,
CurrentPlan = isCurrent,
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { priceId = priceMonth?.Id }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { priceId = priceYear?.Id }),
BaseStorage = priceMonth?.Storage,
BasePriceMonthly = priceMonth?.Amount,
BasePriceYearly = priceYear?.Amount
});
handledFirst = true;
}
}
// Get Email Prices
var curEmailSubs = new List<string>();
if (curSubs.ContainsKey(_config.BillingConfig.EmailProductId))
curEmailSubs = curSubs[_config.BillingConfig.EmailProductId];
var emailProduct = billingService.GetProduct(_config.BillingConfig.EmailProductId);
if (emailProduct != null)
{
SubscriptionId = "email_5gb",
SubscriptionName = "Premium Email",
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "email_5gb_monthly" }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { subscription = "email_5gb_yearly" }),
BaseStorage = 5368709120,
BasePriceMonthly = 3.99,
BasePriceYearly = 39.99,
});
bool handledFirst = false;
foreach (var priceGrp in emailProduct.Prices.GroupBy(p => p.Storage).OrderBy(p => p.Key))
{
// Get Monthly prices
var priceMonth = priceGrp.FirstOrDefault(p => p.Interval == BillingCore.Models.Interval.Month);
// Get Yearly prices
var priceYear = priceGrp.FirstOrDefault(p => p.Interval == BillingCore.Models.Interval.Year);
var isCurrent = curUploadSubs.Exists(s => priceGrp.FirstOrDefault(p => p.ProductId == s) != null);
var emailSub = new SubscriptionViewModel()
{
Recommended = !handledFirst,
CurrentPlan = isCurrent,
SubscribeUrlMonthly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { priceId = priceMonth?.Id }),
SubscribeUrlYearly = Url.SubRouteUrl("billing", "Billing.Subscribe", new { priceId = priceYear?.Id }),
BaseStorage = priceMonth?.Storage,
BasePriceMonthly = priceMonth?.Amount,
BasePriceYearly = priceYear?.Amount
};
if (!handledFirst)
emailSub.PanelOffset = "3";
subVM.EmailSubscriptions.Add(emailSub);
handledFirst = true;
}
}
return View(subVM);
}
@ -104,5 +134,15 @@ namespace Teknik.Areas.Billing.Controllers @@ -104,5 +134,15 @@ namespace Teknik.Areas.Billing.Controllers
{
return View(new PaymentViewModel() { StripePublishKey = _config.BillingConfig.StripePublishApiKey });
}
[AllowAnonymous]
public IActionResult Subscribe(string priceId)
{
// Get Subscription Info
var billingService = BillingFactory.GetStorageService(_config.BillingConfig);
var price = billingService.GetPrice(priceId);
return View(new SubscriptionViewModel());
}
}
}

9
Teknik/Areas/Billing/ViewModels/SubscriptionViewModel.cs

@ -11,13 +11,12 @@ namespace Teknik.Areas.Billing.ViewModels @@ -11,13 +11,12 @@ namespace Teknik.Areas.Billing.ViewModels
public bool Recommended { get; set; }
public bool CurrentPlan { get; set; }
public string SubscriptionId { get; set; }
public string SubscriptionName { get; set; }
public double? BasePriceMonthly { get; set; }
public double? BasePriceYearly { get; set; }
public decimal? BasePriceMonthly { get; set; }
public decimal? BasePriceYearly { get; set; }
public long? BaseStorage { get; set; }
public bool OverageAllowed { get; set; }
public double? OveragePriceMonthly { get; set; }
public double? OveragePriceYearly { get; set; }
public decimal? OveragePriceMonthly { get; set; }
public decimal? OveragePriceYearly { get; set; }
public string OverageUnit { get; set; }
public long? MaxStorage { get; set; }
public string SubscribeUrlYearly { get; set; }

0
Teknik/Areas/Billing/Views/Billing/Subscription.cshtml → Teknik/Areas/Billing/Views/Billing/ViewSubscription.cshtml

4
Teknik/Areas/Billing/Views/Billing/Subscriptions.cshtml → Teknik/Areas/Billing/Views/Billing/ViewSubscriptions.cshtml

@ -19,7 +19,7 @@ @@ -19,7 +19,7 @@
if (subVM.MaxStorage != null)
extraUsage = $"If you need more than {StringHelper.GetBytesReadable(subVM.MaxStorage.Value)} of storage, please contact support for assistance.";
@await Html.PartialAsync("../../Areas/Billing/Views/Billing/Subscription", subVM)
@await Html.PartialAsync("../../Areas/Billing/Views/Billing/ViewSubscription", subVM)
}
</div>
@if (!string.IsNullOrEmpty(extraUsage))
@ -47,7 +47,7 @@ @@ -47,7 +47,7 @@
if (subVM.MaxStorage != null)
extraUsage = $"If you need more than {StringHelper.GetBytesReadable(subVM.MaxStorage.Value)} of storage, please contact support for assistance.";
@await Html.PartialAsync("../../Areas/Billing/Views/Billing/Subscription", subVM)
@await Html.PartialAsync("../../Areas/Billing/Views/Billing/ViewSubscription", subVM)
}
</div>
}

1
Teknik/Teknik.csproj

@ -91,6 +91,7 @@ @@ -91,6 +91,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BillingCore\BillingCore.csproj" />
<ProjectReference Include="..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\ContentScanningService\ContentScanningService.csproj" />
<ProjectReference Include="..\GitService\GitService.csproj" />

Loading…
Cancel
Save