Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Asp.net open source marketplace framework - BeYourMarket

0.00/5 (No votes)
21 Sep 2015 1  
Build your own marketplace with BeYourMarket

Introduction

Many are excited about the new collaborative/sharing economy, where people leverage technologies to get what they need from each other. This has created disruptions for some industries.

What if you want to create a AirBnb for X or TaskRabbit for Y? There is not much framework/tools in .net, where you could build a marketplace easily. This article is to describe an open source framework BeYourMarket to build and customize your own marketplace in minutes!

At the end of this article, you'll have a fully functional marketplace to find the beauty and spa service providers in your neighborhood!

Online demo
http://demo.beyourmarket.com

This framework is ASP.NET MVC 5 based, so you need MS Visual Studio 2013. And, SQL Compact Server/SQL Server 2014 database is required. Web Essentials 2013 and Entity Framework Power Tools are recommended to be installed.

Run Demo with Web Platform

You can start a marketplace in 5 minutes using the Web Platform, BeYourMarket is listed in Web App Gallery http://www.microsoft.com/web/gallery/beyourmarket.aspx

To install BeYourMarket, you just need to click on the Install button on the link above and follow the instruction.

Using the code

The code can be download from github release https://github.com/beyourmarket/beyourmarket/releases

Or else the code can be cloned the solution from github

In command line,

git clone https://github.com/beyourmarket/beyourmarket.git

The solution can be opened with visual studio 2013 with support of .net 4.5

Compile the solution and run the project BeYourMarket.Web. It will launch an installation wizard first time, where you could specify admin username/password and database (either SQL CE compact or MS SQL server).

Remember to check "Install Sample Data", it would create a sample marketplace - beauty and spa service marketplace!

Terminology

Marketplace is a 2-sided market. Some users would provide supply, and some would demand in certain categories and locations. In this sample, it's beauty and spa services in your local area.

In addition, the marketplace would provide a way for the service receivers to pay as well as for the service providers to get paid.

The marketplace itself needs a community manager to promote and maintain the community. It ensures the service quality and resolve the dispute if any. It would normally charge a booking fee when a service is booked. For example, AirBnb charges a booking fee when someone books a room. The more the transaction, the more the profits.

1. Service provider - users who provide the services/products; they will get paid when someone use/buy their services.

2. Service receiver - users who consume the services/products; they will pay for the services/products

3. Community Manager - administrator who creates and manages the marketplace, including listings/orders/transactions/users. He/she also resolves the issues between service providers and receivers.

Create a Marketplace

To create a marketplace, configure the settings in the admin panel. 

BeYourMarket provides an admin panel where the community manager would mange the users/orders/transaction.

Database structure

The database structure is quite simple and straightforward. It uses Entity Framework (EF) with code-first, but database-first is also supported. Models files are structured in the project BeYourMarket.Model

URF - Unit of Work & (extensible/generic) Repositories Framework (see reference) is used for easily extending domains (e.g. new tables/column) mapping between the database and the application.

Create Listings (Supply)

Once users sign up, they could create a listing to their service/product. In this sample, it's the beauty and spa service.

Generally, a Listing contains 4 types of information

1. Item info (Name/Description/Price...)
2. Location info (Latitude/Longitude)
3. Photos
4. Custom/Extra info

*The format of custom info can be defined in the admin panel.

In the ListingController.cs, it's responsible for get/update/delete listing.

In the ListingUpdate method, it handles new or updates listing request.

[HttpPost]
public async Task<ActionResult> ListingUpdate(Item item, FormCollection form, IEnumerable<HttpPostedFileBase> files)
{
    bool updateCount = false;

    int nextPictureOrderId = 0;

    // Add new listing
    if (item.ID == 0)
    {
        item.ObjectState = Repository.Pattern.Infrastructure.ObjectState.Added;
        item.IP = Request.GetVisitorIP();
        item.Expiration = DateTime.MaxValue.AddDays(-1);
        item.UserID = User.Identity.GetUserId();

        updateCount = true;
        _itemService.Insert(item);
    }
    else
    {
        // Update listing
        var itemExisting = await _itemService.FindAsync(item.ID);

        itemExisting.Title = item.Title;
        itemExisting.Description = item.Description;
        itemExisting.CategoryID = item.CategoryID;

        itemExisting.Enabled = item.Enabled;
        itemExisting.Active = item.Active;
        itemExisting.Premium = item.Premium;

        itemExisting.ContactEmail = item.ContactEmail;
        itemExisting.ContactName = item.ContactName;
        itemExisting.ContactPhone = item.ContactPhone;

        itemExisting.Latitude = item.Latitude;
        itemExisting.Longitude = item.Longitude;
        itemExisting.Location = item.Location;

        itemExisting.ShowPhone = item.ShowPhone;
        itemExisting.ShowEmail = item.ShowEmail;

        itemExisting.UserID = item.UserID;

        itemExisting.Price = item.Price;
        itemExisting.Currency = item.Currency;

        itemExisting.ObjectState = Repository.Pattern.Infrastructure.ObjectState.Modified;

        _itemService.Update(itemExisting);
    }

    // Delete existing fields on item
    var customFieldItemQuery = await _customFieldItemService.Query(x => x.ItemID == item.ID).SelectAsync();
    var customFieldIds = customFieldItemQuery.Select(x => x.ID).ToList();
    foreach (var customFieldId in customFieldIds)
    {
        await _customFieldItemService.DeleteAsync(customFieldId);
    }

    // Get custom fields
    var customFieldCategoryQuery = await _customFieldCategoryService.Query(x => x.CategoryID == item.CategoryID).Include(x => x.MetaField.ItemMetas).SelectAsync();
    var customFieldCategories = customFieldCategoryQuery.ToList();

    // Update custom fields
    foreach (var metaCategory in customFieldCategories)
    {
        var field = metaCategory.MetaField;
        var controlType = (BeYourMarket.Model.Enum.Enum_MetaFieldControlType)field.ControlTypeID;

        string controlId = string.Format("customfield_{0}_{1}_{2}", metaCategory.ID, metaCategory.CategoryID, metaCategory.FieldID);

        var formValue = form[controlId];

        if (string.IsNullOrEmpty(formValue))
            continue;

        formValue = formValue.ToString();

        var itemMeta = new ItemMeta()
        {
            ItemID = item.ID,
            Value = formValue,
            FieldID = field.ID,
            ObjectState = Repository.Pattern.Infrastructure.ObjectState.Added
        };

        _customFieldItemService.Insert(itemMeta);
    }

    await _unitOfWorkAsync.SaveChangesAsync();

    // Update photos
    if (Request.Files.Count > 0)
    {
        var itemPictureQuery = _itemPictureService.Queryable().Where(x => x.ItemID == item.ID);
        if (itemPictureQuery.Count() > 0)
            nextPictureOrderId = itemPictureQuery.Max(x => x.Ordering);
    }

    foreach (HttpPostedFileBase file in files)
    {
        if ((file != null) && (file.ContentLength > 0) && !string.IsNullOrEmpty(file.FileName))
        {
            // Picture picture and get id
            var picture = new Picture();
            picture.MimeType = "image/jpeg";
            _pictureService.Insert(picture);
            await _unitOfWorkAsync.SaveChangesAsync();

            // Format is automatically detected though can be changed.
            ISupportedImageFormat format = new JpegFormat { Quality = 90 };
            Size size = new Size(500, 0);

            //https://naimhamadi.wordpress.com/2014/06/25/processing-images-in-c-easily-using-imageprocessor/
            // Initialize the ImageFactory using the overload to preserve EXIF metadata.
            using (ImageFactory imageFactory = new ImageFactory(preserveExifData: true))
            {
                var path = Path.Combine(Server.MapPath("~/images/item"), string.Format("{0}.{1}", picture.ID.ToString("00000000"), "jpg"));

                // Load, resize, set the format and quality and save an image.
                imageFactory.Load(file.InputStream)
                            .Resize(size)
                            .Format(format)
                            .Save(path);
            }

            var itemPicture = new ItemPicture();
            itemPicture.ItemID = item.ID;
            itemPicture.PictureID = picture.ID;
            itemPicture.Ordering = nextPictureOrderId;

            _itemPictureService.Insert(itemPicture);

            nextPictureOrderId++;
        }
    }

    await _unitOfWorkAsync.SaveChangesAsync();

    // Update statistics count
    if (updateCount)
    {
        _sqlDbService.UpdateCategoryItemCount(item.CategoryID);
        _dataCacheService.RemoveCachedItem(CacheKeys.Statistics);
    }

    return RedirectToAction("Listings");
}

Service Booking (Demand)

Once there are some listings (services/products) which users can book and pay, we need to create orders for these request.

PaymentController.cs handles all the order and payment requests.

In the Order method, it creates order with what users requested; when it succeeds, it would redirect the user to the payment page afterwards.

public async Task<ActionResult> Order(Order order)
        {
            var item = await _itemService.FindAsync(order.ItemID);

            if (item == null)
                return new HttpNotFoundResult();

            // Check if payment method is setup on user or the platform
            var descriptors = _pluginFinder.GetPluginDescriptors<IHookPlugin>(LoadPluginsMode.InstalledOnly, "Payment").Where(x => x.Enabled);
            if (descriptors.Count() == 0)
            {
                TempData[TempDataKeys.UserMessageAlertState] = "bg-danger";
                TempData[TempDataKeys.UserMessage] = "[[[The provider has not setup the payment option yet, please contact the provider.]]]";

                return RedirectToAction("Listing", "Listing", new { id = order.ItemID });
            }

            foreach (var descriptor in descriptors)
            {
                var controllerType = descriptor.Instance<IHookPlugin>().GetControllerType();
                var controller = ContainerManager.GetConfiguredContainer().Resolve(controllerType) as IPaymentController;

                if (!controller.HasPaymentMethod(item.UserID))
                {
                    TempData[TempDataKeys.UserMessageAlertState] = "bg-danger";
                    TempData[TempDataKeys.UserMessage] = string.Format("[[[The provider has not setup the payment option for {0} yet, please contact the provider.]]]", descriptor.FriendlyName);

                    return RedirectToAction("Listing", "Listing", new { id = order.ItemID });
                }   
            }            

            if (order.ID == 0)
            {
                order.ObjectState = Repository.Pattern.Infrastructure.ObjectState.Added;
                order.Created = DateTime.Now;
                order.Modified = DateTime.Now;
                order.Status = (int)Enum_OrderStatus.Created;
                order.UserProvider = item.UserID;
                order.UserReceiver = User.Identity.GetUserId();

                if (order.UserProvider == order.UserReceiver)
                {
                    TempData[TempDataKeys.UserMessageAlertState] = "bg-danger";
                    TempData[TempDataKeys.UserMessage] = "[[[You cannot book the item from yourself!]]]";

                    return RedirectToAction("Listing", "Listing", new { id = order.ItemID });
                }

                if (order.ToDate.HasValue && order.FromDate.HasValue)
                {
                    order.Description = string.Format("{0} #{1} ([[[From]]] {2} [[[To]]] {3})",
                        item.Title, item.ID,
                        order.FromDate.Value.ToShortDateString(), order.ToDate.Value.ToShortDateString());

                    order.Quantity = order.ToDate.Value.Date.AddDays(1).Subtract(order.FromDate.Value.Date).Days;
                    order.Price = order.Quantity * item.Price;
                }
                else
                {
                    order.Description = string.Format("{0} #{1}", item.Title, item.ID);
                    order.Quantity = 1;
                    order.Price = item.Price;
                }

                _orderService.Insert(order);
            }

            await _unitOfWorkAsync.SaveChangesAsync();

            ClearCache();

            return RedirectToAction("Payment", new { id = order.ID });
        }

Payment integration with Stripe

Services/Products in marketplaces need to get users paid. Stripe provides a protocol to accept payments. BeYourMarket is integated with Stripe Connect API, so the users can pay and get paid (even with BitCoin!) easily. If needed, it can also be integrated with other payment API like braintree/paypal checkout express by writing a plugin in BeYourMarket.

In Payment.cshtml, it embeds the stripe form with checkout. With the following html code, it would take care of building forms, validating input, and securing your users' card data.

The key thing to notice is the data-key attribute, which identifies your website when communicating with Stripe. The publishable API key can be configured in the Admin Panel.

 <div class="form-group">
    <form class="form" action="@Url.Action("Payment")" method="post" role="form">
        <script src="https://checkout.stripe.com/checkout.js" class="stripe-button"
                data-key="@CacheHelper.GetSettingDictionary(Plugin.Payment.Stripe.StripePlugin.SettingStripePublishableKey).Value"
                data-image="https://stripe.com/img/documentation/checkout/marketplace.png"
                data-name="@CacheHelper.Settings.Name"
                data-description="@Model.Description"
                data-currency="@CacheHelper.Settings.Currency"
                data-amount="@Model.PriceInCents">

        </script>
    </form>
</div>

Once the user payed with their credit card, Stripe would generate and send back a secured token that could be used to charge them money. In the marketplace, the service provider usually would need to confirm their availability and accept the offer before the transaction is done. Therefore, we could save the token and create a transaction to be captured later on.

In PaymentController.cs, the Payment method handles the callback from Stripe after the user enter a valid card number and click pay. It contains a secured token as well as the orderid.

[HttpPost]

public async Task<ActionResult> Payment(int id, string stripeToken, string stripeEmail)
        {
            var selectQuery = await _orderService.Query(x => x.ID == id).Include(x => x.Item).SelectAsync();

            // Check if order exists
            var order = selectQuery.FirstOrDefault();
            if (order == null)
                return new HttpNotFoundResult();

            var stripeConnectQuery = await _stripConnectService.Query(x => x.UserID == order.UserProvider).SelectAsync();
            var stripeConnect = stripeConnectQuery.FirstOrDefault();

            if (stripeConnect == null)
                return new HttpNotFoundResult();

            //https://stripe.com/docs/checkout
            var charge = new StripeChargeCreateOptions();

            // always set these properties
            charge.Amount = order.PriceInCents;
            charge.Currency = CacheHelper.Settings.Currency;
            charge.Source = new StripeSourceOptions()
            {
                TokenId = stripeToken
            };

            // set booking fee
            var bookingFee = (int)Math.Round(CacheHelper.Settings.TransactionFeePercent * order.PriceInCents);
            if (bookingFee < CacheHelper.Settings.TransactionMinimumFee * 100)
                bookingFee = (int)(CacheHelper.Settings.TransactionMinimumFee * 100);

            charge.ApplicationFee = bookingFee;
            charge.Capture = false;
            charge.Description = order.Description;
            charge.Destination = stripeConnect.stripe_user_id;
            var chargeService = new StripeChargeService(CacheHelper.GetSettingDictionary("StripeApiKey").Value);
            StripeCharge stripeCharge = chargeService.Create(charge);

            // Update order status
            order.Status = (int)Enum_OrderStatus.Pending;
            order.PaymentPlugin = StripePlugin.PluginName;
            _orderService.Update(order);

            // Save transaction
            var transaction = new StripeTransaction()
            {
                OrderID = id,
                ChargeID = stripeCharge.Id,
                StripeEmail = stripeEmail,
                StripeToken = stripeToken,
                Created = DateTime.Now,
                LastUpdated = DateTime.Now,
                FailureCode = stripeCharge.FailureCode,
                FailureMessage = stripeCharge.FailureMessage,
                ObjectState = Repository.Pattern.Infrastructure.ObjectState.Added
            };

            _transactionService.Insert(transaction);

            await _unitOfWorkAsync.SaveChangesAsync();
            await _unitOfWorkAsyncStripe.SaveChangesAsync();

            ClearCache();

            // Payment succeeded
            if (string.IsNullOrEmpty(stripeCharge.FailureCode))
            {
                TempData[TempDataKeys.UserMessage] = "[[[Thanks for your order! You payment will not be charged until the provider accepted your request.]]]";
                return RedirectToAction("Orders", "Payment");
            }
            else
            {
                TempData[TempDataKeys.UserMessageAlertState] = "bg-danger";
                TempData[TempDataKeys.UserMessage] = stripeCharge.FailureMessage;

                return RedirectToAction("Payment");
            }
        }

Accept payments and charge transaction fee

Once the service provider accepts what the user requested through the dashboard panel. The transaction would need to be captured. Money will get charged from the user's credit card, and service provider will get paid and provide the service, and the community manager will be awarded with a booking fee. Everyone is happy!

In the PaymentController.cs, the OrderAction handles the order action if service provider accept or decline the request. If he/she accepts, it would create a charge with Stripe API and capture the payment.

[HttpPost]

public async Task<ActionResult> OrderAction(int id, int status)
        {
            var order = await _orderService.FindAsync(id);

            if (order == null)
                return new HttpNotFoundResult();

            var descriptor = _pluginFinder.GetPluginDescriptorBySystemName<IHookPlugin>(order.PaymentPlugin);
            if (descriptor == null)
                return new HttpNotFoundResult("Not found");

            var controllerType = descriptor.Instance<IHookPlugin>().GetControllerType();
            var controller = ContainerManager.GetConfiguredContainer().Resolve(controllerType) as IPaymentController;

            string message = string.Empty;
            var orderResult = controller.OrderAction(id, status, out message);

            var result = new
            {
                Success = orderResult,
                Message = message
            };

            return Json(result, JsonRequestBehavior.AllowGet);
        }

Communication

BeYourMarket has an message box system which allows service providers and receivers to communciate with each other. 

Message thread would be created whenever a user start a conversion first time. If there is a conversion between the users (MessageParticipanets) before, the same message thread will be used. Each message thread contains a list of messages. Each message would have a state if it's read or not as well.

public partial class MessageThread : Repository.Pattern.Ef6.Entity
{
    public MessageThread()
    {
        this.Messages = new List<Message>();
        this.MessageParticipants = new List<MessageParticipant>();
    }

    public int ID { get; set; }
    public string Subject { get; set; }
    public Nullable<int> ListingID { get; set; }
    public System.DateTime Created { get; set; }
    public System.DateTime LastUpdated { get; set; }
    public virtual Listing Listing { get; set; }
    public virtual ICollection<Message> Messages { get; set; }
    public virtual ICollection<MessageParticipant> MessageParticipants { get; set; }
}

Each message thread is associated with each service provider and receiver can also associated on a specific listing (service or product), If a message is unread, a notification icon will be shown on the top navigation bar.

Review and Rating

After the service, both server provider and receiver would have a chance to give feedback and ratings to each others. In this way, other users would be able to reviews about a service or product and whether it's good or not.

Internalization

The platform is by designed with support of multiple language with i18n easily. Smart internationalization for ASP.NET based on GetText / PO ecosystem (see reference) is used. The only thing you would need is to translate into your own language.

To localize text in your application, surround your strings with [[[ and ]]] markup characters to mark them as translatable.

Here's an example of localizing text "Create an account" and "Log in" in a Razor view:

<ul class="nav navbar-nav">
    <li class="dropdown messages-menu hidden-xs">
        @Html.ActionLink("[[[Create an account]]]", "Register", "Account", new { area = string.Empty }, htmlAttributes: new { id = "registerLink" })
    </li>
    <li class="dropdown messages-menu hidden-xs">
        @Html.ActionLink("[[[Log in]]]", "Login", "Account", new { area = string.Empty }, htmlAttributes: new { id = "loginLink" })
    </li>
</ul>

The template file is located at locale/messages.pot after the solution is built.

Conclusion

At the end, we would see how an beauty and spa marketplace is built with asp.net mvc. Collorative consumption is a trend and more tradtional business will be transformed into marketplace business.

Technology would need to be easily customized and scaled in order to improve the product time-to-market. BeYourMarket is started with an initiative, that an opensource framework where developers could help business build and customize marketplaces easily. It shall be also easy to extend with other features and components.

License

BeYourMarket is licensed with MIT License.

Reference

BeYourMarket - An opensource asp.net marketplace - http://beyourmarket.com

BeYourMarket Documentation - https://beyourmarket.atlassian.net/wiki/display/BYM/BeYourMarket

Smart internationalization for ASP.NET - https://github.com/turquoiseowl/i18n

URF - Unit of Work & (extensible/generic) Repositories Framework - https://genericunitofworkandrepositories.codeplex.com/

History

3.09.2015 - Updated with database, Add communication, and review/rating section

21.07.2015 - Updated with plugin architecture support and database diagram

20.06.2015 - Introduction to OpenSource asp.net marketplace framework

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here