diff --git a/Examples/BookingSystem.AspNetCore/Extensions/FeedGeneratorHelper.cs b/Examples/BookingSystem.AspNetCore/Extensions/FeedGeneratorHelper.cs new file mode 100644 index 00000000..7c90520e --- /dev/null +++ b/Examples/BookingSystem.AspNetCore/Extensions/FeedGeneratorHelper.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.Server.NET.OpenBookingHelper; + +namespace BookingSystem +{ + public static class FeedGeneratorHelper + { + public static List OpenBookingFlowRequirement(bool requiresApproval, bool requiresAttendeeValidation, bool requiresAdditionalDetails, bool allowsProposalAmendment) + { + List openBookingFlowRequirement = null; + + if (requiresApproval) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); + } + + if (requiresAttendeeValidation) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + } + + if (requiresAdditionalDetails) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); + } + + if (allowsProposalAmendment) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); + } + return openBookingFlowRequirement; + } + + public static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) + { + switch (attendanceMode) + { + case AttendanceMode.Offline: + return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; + case AttendanceMode.Online: + return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; + case AttendanceMode.Mixed: + return EventAttendanceModeEnumeration.MixedEventAttendanceMode; + default: + throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); + } + } + } +} diff --git a/Examples/BookingSystem.AspNetCore/Extensions/StoreHelper.cs b/Examples/BookingSystem.AspNetCore/Extensions/StoreHelper.cs new file mode 100644 index 00000000..90edae1e --- /dev/null +++ b/Examples/BookingSystem.AspNetCore/Extensions/StoreHelper.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.Server.NET.OpenBookingHelper; + +namespace BookingSystem +{ + public static class StoreHelper + { + public static List GetUnitTaxSpecification(BookingFlowContext flowContext, AppSettings appSettings, decimal? price) + { + switch (flowContext.TaxPayeeRelationship) + { + case TaxPayeeRelationship.BusinessToBusiness when appSettings.Payment.TaxCalculationB2B: + case TaxPayeeRelationship.BusinessToConsumer when appSettings.Payment.TaxCalculationB2C: + return new List + { + new TaxChargeSpecification + { + Name = "VAT at 20%", + Price = price * (decimal?)0.2, + PriceCurrency = "GBP", + Rate = (decimal?)0.2 + } + }; + case TaxPayeeRelationship.BusinessToBusiness when !appSettings.Payment.TaxCalculationB2B: + case TaxPayeeRelationship.BusinessToConsumer when !appSettings.Payment.TaxCalculationB2C: + return null; + default: + throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/Examples/BookingSystem.AspNetCore/Feeds/EventsFeed.cs b/Examples/BookingSystem.AspNetCore/Feeds/EventsFeed.cs new file mode 100644 index 00000000..fa1ee483 --- /dev/null +++ b/Examples/BookingSystem.AspNetCore/Feeds/EventsFeed.cs @@ -0,0 +1,172 @@ +using OpenActive.DatasetSite.NET; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.NET.Rpde.Version1; +using OpenActive.Server.NET.OpenBookingHelper; +using ServiceStack.OrmLite; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BookingSystem +{ + public class AcmeEventRpdeGenerator : RpdeFeedModifiedTimestampAndIdLong + { + private readonly bool _useSingleSellerMode; + + // Example constructor that can set state from EngineConfig + public AcmeEventRpdeGenerator(bool useSingleSellerMode) + { + this._useSingleSellerMode = useSingleSellerMode; + } + + protected async override Task>> GetRpdeItems(long? afterTimestamp, long? afterId) + { + using (var db = FakeBookingSystem.Database.Mem.Database.Open()) + { + var q = db.From() + .Join() + .OrderBy(x => x.Modified) + .ThenBy(x => x.Id) + .Where(x => x.IsEvent) // Filters for Events only + .Where(x => !afterTimestamp.HasValue && !afterId.HasValue || + x.Modified > afterTimestamp || + x.Modified == afterTimestamp && x.Id > afterId && + x.Modified < (DateTimeOffset.UtcNow - new TimeSpan(0, 0, 2)).UtcTicks) + .Take(RpdePageSize); + + var query = db + .SelectMulti(q) + .Select(result => new RpdeItem + { + Kind = RpdeKind.Event, + Id = result.Item1.Id, + Modified = result.Item1.Modified, + State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = result.Item1.Deleted ? null : new Event + { + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) + Id = RenderOpportunityId(new EventOpportunity + { + OpportunityType = OpportunityType.Event, + EventId = result.Item1.Id, + }), + Name = result.Item1.Title, + EventAttendanceMode = FeedGeneratorHelper.MapAttendanceMode(result.Item1.AttendanceMode), + Organizer = _useSingleSellerMode ? new Organization + { + Id = RenderSingleSellerId(), + Name = "Test Seller", + TaxMode = TaxMode.TaxGross, + TermsOfService = new List + { + new PrivacyPolicy + { + Name = "Privacy Policy", + Url = new Uri("https://example.com/privacy.html"), + RequiresExplicitConsent = false + } + }, + IsOpenBookingAllowed = true, + } : result.Item2.IsIndividual ? (ILegalEntity)new Person + { + Id = RenderSellerId(new SellerIdComponents { SellerIdLong = result.Item2.Id }), + Name = result.Item2.Name, + TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + IsOpenBookingAllowed = true, + } : (ILegalEntity)new Organization + { + Id = RenderSellerId(new SellerIdComponents { SellerIdLong = result.Item2.Id }), + Name = result.Item2.Name, + TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + TermsOfService = new List + { + new PrivacyPolicy + { + Name = "Privacy Policy", + Url = new Uri("https://example.com/privacy.html"), + RequiresExplicitConsent = false + } + }, + IsOpenBookingAllowed = true, + }, + Offers = new List { new Offer + { + Id = RenderOfferId(new EventOpportunity + { + OpportunityType = OpportunityType.Event, + EventId = result.Item1.Id, + OfferId = 0 + }), + Price = result.Item1.Price, + PriceCurrency = "GBP", + OpenBookingFlowRequirement = FeedGeneratorHelper.OpenBookingFlowRequirement( + result.Item1.RequiresApproval, + result.Item1.RequiresAttendeeValidation, + result.Item1.RequiresAdditionalDetails, + result.Item1.AllowsProposalAmendment), + ValidFromBeforeStartDate = result.Item1.ValidFromBeforeStartDate, + LatestCancellationBeforeStartDate = result.Item1.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = result.Item1.Prepayment.Convert(), + AllowCustomerCancellationFullRefund = result.Item1.AllowCustomerCancellationFullRefund + } + }, + Location = result.Item1.AttendanceMode == AttendanceMode.Online ? null : new Place + { + Name = "Fake Pond", + Address = new PostalAddress + { + StreetAddress = "1 Fake Park", + AddressLocality = "Another town", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1AA", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = result.Item1.LocationLat, + Longitude = result.Item1.LocationLng, + } + }, + AffiliatedLocation = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : new Place + { + Name = "Fake Pond", + Address = new PostalAddress + { + StreetAddress = "1 Fake Park", + AddressLocality = "Another town", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1AA", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = result.Item1.LocationLat, + Longitude = result.Item1.LocationLng, + } + }, + Url = new Uri("https://www.example.com/a-session-age"), + Activity = new List + { + new Concept + { + Id = new Uri("https://openactive.io/activity-list#c07d63a0-8eb9-4602-8bcc-23be6deb8f83"), + PrefLabel = "Jet Skiing", + InScheme = new Uri("https://openactive.io/activity-list") + } + }, + StartDate = (DateTimeOffset)result.Item1.Start, + EndDate = (DateTimeOffset)result.Item1.End, + Duration = result.Item1.End - result.Item1.Start, + RemainingAttendeeCapacity = result.Item1.RemainingSpaces - result.Item1.LeasedSpaces, + MaximumAttendeeCapacity = result.Item1.TotalSpaces + } + }); + return query.ToList(); + } + } + } +} diff --git a/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs b/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs index a53060f2..8f3cc876 100644 --- a/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs +++ b/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs @@ -175,7 +175,11 @@ protected async override Task>> GetRpdeItems(long? afterTime }), Price = x.Price, PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(x), + OpenBookingFlowRequirement = FeedGeneratorHelper.OpenBookingFlowRequirement( + x.RequiresApproval, + x.RequiresAttendeeValidation, + x.RequiresAdditionalDetails, + x.AllowsProposalAmendment), ValidFromBeforeStartDate = x.ValidFromBeforeStartDate, LatestCancellationBeforeStartDate = x.LatestCancellationBeforeStartDate, OpenBookingPrepayment = x.Prepayment.Convert(), @@ -188,35 +192,5 @@ protected async override Task>> GetRpdeItems(long? afterTime return query.ToList(); } } - - private static List OpenBookingFlowRequirement(SlotTable slot) - { - List openBookingFlowRequirement = null; - - if (slot.RequiresApproval) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); - } - - if (slot.RequiresAttendeeValidation) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); - } - - if (slot.RequiresAdditionalDetails) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); - } - - if (slot.AllowsProposalAmendment) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); - } - return openBookingFlowRequirement; - } } } \ No newline at end of file diff --git a/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs b/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs index f9d0e5cf..56b77185 100644 --- a/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs +++ b/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs @@ -80,6 +80,7 @@ protected async override Task>> GetRpdeItems(long? .Join() .OrderBy(x => x.Modified) .ThenBy(x => x.Id) + .Where(x => !x.IsEvent) // Filters for SessionSeries only (as opposed to Events) .Where(x => !afterTimestamp.HasValue && !afterId.HasValue || x.Modified > afterTimestamp || x.Modified == afterTimestamp && x.Id > afterId && @@ -105,7 +106,7 @@ protected async override Task>> GetRpdeItems(long? SessionSeriesId = result.Item1.Id }), Name = result.Item1.Title, - EventAttendanceMode = MapAttendanceMode(result.Item1.AttendanceMode), + EventAttendanceMode = FeedGeneratorHelper.MapAttendanceMode(result.Item1.AttendanceMode), Organizer = _useSingleSellerMode ? new Organization { Id = RenderSingleSellerId(), @@ -153,7 +154,11 @@ protected async override Task>> GetRpdeItems(long? }), Price = result.Item1.Price, PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(result.Item1), + OpenBookingFlowRequirement = FeedGeneratorHelper.OpenBookingFlowRequirement( + result.Item1.RequiresApproval, + result.Item1.RequiresAttendeeValidation, + result.Item1.RequiresAdditionalDetails, + result.Item1.AllowsProposalAmendment), ValidFromBeforeStartDate = result.Item1.ValidFromBeforeStartDate, LatestCancellationBeforeStartDate = result.Item1.LatestCancellationBeforeStartDate, OpenBookingPrepayment = result.Item1.Prepayment.Convert(), @@ -203,7 +208,8 @@ protected async override Task>> GetRpdeItems(long? PrefLabel = "Jet Skiing", InScheme = new Uri("https://openactive.io/activity-list") } - } + }, + EventSchedule = HydatePartialSchedules(result.Item1.PartialScheduleDay, result.Item1.PartialScheduleTime, result.Item1.PartialScheduleDuration), } }); ; @@ -211,49 +217,21 @@ protected async override Task>> GetRpdeItems(long? } } - private static List OpenBookingFlowRequirement(ClassTable @class) + private List HydatePartialSchedules(DayOfWeek partialScheduleDayOfWeek, DateTimeOffset partialScheduleStartTime, TimeSpan partialScheduleDuration) { - List openBookingFlowRequirement = null; - - if (@class.RequiresApproval) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); - } - - if (@class.RequiresAttendeeValidation) + var schedules = new List() { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); - } - - if (@class.RequiresAdditionalDetails) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); - } - - if (@class.AllowsProposalAmendment) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); - } - return openBookingFlowRequirement; - } + new PartialSchedule() + { + ByDay = new List() { $"https://schema.org/{partialScheduleDayOfWeek}" }, + StartTime = partialScheduleStartTime, + Duration = partialScheduleDuration, + EndTime = partialScheduleStartTime.Add(partialScheduleDuration), + ScheduleTimezone = "Europe/London", + } + }; - private static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) - { - switch (attendanceMode) - { - case AttendanceMode.Offline: - return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; - case AttendanceMode.Online: - return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; - case AttendanceMode.Mixed: - return EventAttendanceModeEnumeration.MixedEventAttendanceMode; - default: - throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); - } + return schedules; } } } diff --git a/Examples/BookingSystem.AspNetCore/IdComponents/EventOpportunity.cs b/Examples/BookingSystem.AspNetCore/IdComponents/EventOpportunity.cs new file mode 100644 index 00000000..b6602e16 --- /dev/null +++ b/Examples/BookingSystem.AspNetCore/IdComponents/EventOpportunity.cs @@ -0,0 +1,48 @@ +using OpenActive.DatasetSite.NET; +using OpenActive.Server.NET.OpenBookingHelper; + +namespace BookingSystem +{ + /// + /// These classes must be created by the booking system, the below are some simple examples. + /// These should be created alongside the IdConfiguration and OpenDataFeeds settings, as the two work together + /// + /// They can be completely customised to match the preferred ID structure of the booking system + /// + /// There is a choice of `string`, `long?` and `Uri` available for each component of the ID + /// + public class EventOpportunity : IBookableIdComponents + { + public OpportunityType? OpportunityType { get; set; } + public long? EventId { get; set; } + public long? OfferId { get; set; } + + public override bool Equals(object obj) + { + var other = obj as EventOpportunity; + if (ReferenceEquals(other, null)) + return false; + + return EventId == other.EventId && + OfferId == other.OfferId; + } + + public override int GetHashCode() + { + unchecked + { + // ReSharper disable NonReadonlyMemberInGetHashCode + var hashCode = OpportunityType.GetHashCode(); + hashCode = (hashCode * 397) ^ EventId.GetHashCode(); + hashCode = (hashCode * 397) ^ OfferId.GetHashCode(); + // ReSharper enable NonReadonlyMemberInGetHashCode + return hashCode; + } + } + + public static bool operator ==(EventOpportunity x, EventOpportunity y) => x != null && x.Equals(y); + + public static bool operator !=(EventOpportunity x, EventOpportunity y) => !(x == y); + + } +} diff --git a/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs b/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs index 43446489..38a8dba8 100644 --- a/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs +++ b/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs @@ -53,7 +53,19 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting OpportunityType = OpportunityType.FacilityUse, AssignedFeed = OpportunityType.FacilityUse, OpportunityIdTemplate = "{+BaseUrl}/facility-uses/{FacilityUseId}" - })/*, + }), + new BookablePairIdTemplate( + // Opportunity + new OpportunityIdConfiguration + { + OpportunityType = OpportunityType.Event, + AssignedFeed = OpportunityType.Event, + OpportunityIdTemplate = "{+BaseUrl}/events/{EventId}", + OfferIdTemplate = "{+BaseUrl}/events/{EventId}#/offers/{OfferId}", + Bookable = true + }) + + /*, new BookablePairIdTemplate( // Opportunity @@ -91,18 +103,7 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting OpportunityUriTemplate = "{+BaseUrl}/courses/{CourseId}", OfferUriTemplate = "{+BaseUrl}/courses/{CourseId}#/offers/{OfferId}", Bookable = true - }), - - new BookablePairIdTemplate( - // Opportunity - new OpportunityIdConfiguration - { - OpportunityType = OpportunityType.Event, - AssignedFeed = OpportunityType.Event, - OpportunityUriTemplate = "{+BaseUrl}/events/{EventId}", - OfferUriTemplate = "{+BaseUrl}/events/{EventId}#/offers/{OfferId}", - Bookable = true - })*/ + }),*/ }, @@ -148,6 +149,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting }, { OpportunityType.FacilityUseSlot, new AcmeFacilityUseSlotRpdeGenerator() + }, + { + OpportunityType.Event, new AcmeEventRpdeGenerator(appSettings.FeatureFlags.SingleSeller) } }, @@ -252,6 +256,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting }, { new FacilityStore(appSettings), new List { OpportunityType.FacilityUseSlot } + }, + { + new EventStore(appSettings), new List { OpportunityType.Event } } }, OrderStore = new AcmeOrderStore(appSettings), diff --git a/Examples/BookingSystem.AspNetCore/Stores/EventStore.cs b/Examples/BookingSystem.AspNetCore/Stores/EventStore.cs new file mode 100644 index 00000000..911b7927 --- /dev/null +++ b/Examples/BookingSystem.AspNetCore/Stores/EventStore.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OpenActive.DatasetSite.NET; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.Server.NET.OpenBookingHelper; +using OpenActive.Server.NET.StoreBooking; +using ServiceStack.OrmLite; +using RequiredStatusType = OpenActive.FakeDatabase.NET.RequiredStatusType; + +namespace BookingSystem +{ + class EventStore : OpportunityStore + { + private readonly AppSettings _appSettings; + + // Example constructor that can set state from EngineConfig. This is not required for an actual implementation. + public EventStore(AppSettings appSettings) + { + _appSettings = appSettings; + } + + Random rnd = new Random(); + + protected async override Task CreateOpportunityWithinTestDataset( + string testDatasetIdentifier, + OpportunityType opportunityType, + TestOpportunityCriteriaEnumeration criteria, + TestOpenBookingFlowEnumeration openBookingFlow, + SellerIdComponents seller) + { + if (!_appSettings.FeatureFlags.SingleSeller && !seller.SellerIdLong.HasValue) + throw new OpenBookingException(new OpenBookingError(), "Seller must have an ID in Multiple Seller Mode"); + + long? sellerId = _appSettings.FeatureFlags.SingleSeller ? null : seller.SellerIdLong; + var requiresApproval = openBookingFlow == TestOpenBookingFlowEnumeration.OpenBookingApprovalFlow; + + switch (opportunityType) + { + case OpportunityType.Event: + int eventId; + switch (criteria) + { + case TestOpportunityCriteriaEnumeration.TestOpportunityBookable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Event", + rnd.Next(2) == 0 ? 0M : 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellable: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFree: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableUsingPayment: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableWithinValidFromBeforeStartDate: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableOutsideValidFromBeforeStartDate: + { + var isValid = criteria == TestOpportunityCriteriaEnumeration.TestOpportunityBookableWithinValidFromBeforeStartDate; + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + $"[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event {(isValid ? "Within" : "Outside")} Window", + 14.99M, + 10, + requiresApproval, + validFromStartDate: isValid); + } + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellableWithinWindow: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellableOutsideWindow: + { + var isValid = criteria == TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellableWithinWindow; + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + $"[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event {(isValid ? "Within" : "Outside")} Cancellation Window", + 14.99M, + 10, + requiresApproval, + latestCancellationBeforeStartDate: isValid); + } + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreePrepaymentOptional: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Prepayment Optional", + 10M, + 10, + requiresApproval, + prepayment: RequiredStatusType.Optional); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreePrepaymentUnavailable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Prepayment Unavailable", + 10M, + 10, + requiresApproval, + prepayment: RequiredStatusType.Unavailable); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreePrepaymentRequired: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Prepayment Required", + 10M, + 10, + requiresApproval, + prepayment: RequiredStatusType.Required); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableFree: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Free Event", + 0M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNoSpaces: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Free Event No Spaces", + 14.99M, + 0, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableFiveSpaces: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Free Event Five Spaces", + 14.99M, + 5, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreeTaxNet: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + 2, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Tax Net", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreeTaxGross: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + 1, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Tax Gross", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableSellerTermsOfService: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + 1, + "[OPEN BOOKING API TEST INTERFACE] Bookable Event With Seller Terms Of Service", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableAttendeeDetails: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event That Requires Attendee Details", + 10M, + 10, + requiresApproval, + requiresAttendeeValidation: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableAdditionalDetails: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event That Requires Additional Details", + 10M, + 10, + requiresApproval, + requiresAdditionalDetails: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityOnlineBookable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Virtual Event", + 10M, + 10, + requiresApproval, + isOnlineOrMixedAttendanceMode: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityOfflineBookable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Offline Event", + 10M, + 10, + requiresApproval, + isOnlineOrMixedAttendanceMode: false); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableWithNegotiation: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event That Allows Proposal Amendment", + 10M, + 10, + requiresApproval, + allowProposalAmendment: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNotCancellable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid That Does Not Allow Full Refund", + 10M, + 10, + requiresApproval, + allowCustomerCancellationFullRefund: false); + break; + default: + throw new OpenBookingException(new OpenBookingError(), "testOpportunityCriteria value not supported"); + } + + return new EventOpportunity + { + OpportunityType = opportunityType, + EventId = eventId + }; + + default: + throw new OpenBookingException(new OpenBookingError(), "Opportunity Type not supported"); + } + } + + protected async override Task DeleteTestDataset(string testDatasetIdentifier) + { + FakeBookingSystem.Database.DeleteTestEventsFromDataset(testDatasetIdentifier); + } + + protected async override Task TriggerTestAction(OpenBookingSimulateAction simulateAction, EventOpportunity idComponents) + { + switch (simulateAction) + { + case ChangeOfLogisticsTimeSimulateAction _: + if (!FakeBookingSystem.Database.UpdateEventStartAndEndTimeByPeriodInMins(idComponents.EventId.Value, 60)) + { + throw new OpenBookingException(new UnknownOpportunityError()); + } + return; + case ChangeOfLogisticsNameSimulateAction _: + if (!FakeBookingSystem.Database.UpdateEventTitle(idComponents.EventId.Value, "Updated Event Title")) + { + throw new OpenBookingException(new UnknownOpportunityError()); + } + return; + case ChangeOfLogisticsLocationSimulateAction _: + if (!FakeBookingSystem.Database.UpdateEventLocationLatLng(idComponents.EventId.Value, 0.2m, 0.3m)) + { + throw new OpenBookingException(new UnknownOpportunityError()); + } + return; + default: + throw new NotImplementedException(); + } + + } + + // Similar to the RPDE logic, this needs to render and return an new hypothetical OrderItem from the database based on the supplied opportunity IDs + protected async override Task GetOrderItems(List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext) + { + // Note the implementation of this method must also check that this OrderItem is from the Seller specified by context.SellerIdComponents (this is not required if using a Single Seller) + + // Additionally this method must check that there are enough spaces in each entry + + // Response OrderItems must be updated into supplied orderItemContexts (including duplicates for multi-party booking) + + var query = orderItemContexts.Select((orderItemContext) => + { + var getOccurrenceInfoResult = FakeBookingSystem.Database.GetEventAndBookedOrderItemInfoByEventId(flowContext.OrderId.uuid, orderItemContext.RequestBookableOpportunityOfferId.EventId); + var (hasFoundOccurrence, @event, bookedOrderItemInfo) = getOccurrenceInfoResult; + if (hasFoundOccurrence == false) + { + return null; + } + var remainingUsesIncludingOtherLeases = FakeBookingSystem.Database.GetNumberOfOtherLeasesForEvent(flowContext.OrderId.uuid, orderItemContext.RequestBookableOpportunityOfferId.EventId); + + return new + { + OrderItem = new OrderItem + { + // TODO: The static example below should come from the database (which doesn't currently support tax) + UnitTaxSpecification = StoreHelper.GetUnitTaxSpecification(flowContext, _appSettings, @event.Price), + AcceptedOffer = new Offer + { + // Note this should always use RenderOfferId with the supplied SessionFacilityOpportunity, to take into account inheritance and OfferType + Id = RenderOfferId(orderItemContext.RequestBookableOpportunityOfferId), + Price = @event.Price, + PriceCurrency = "GBP", + LatestCancellationBeforeStartDate = @event.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = @event.Prepayment.Convert(), + ValidFromBeforeStartDate = @event.ValidFromBeforeStartDate, + AllowCustomerCancellationFullRefund = @event.AllowCustomerCancellationFullRefund, + }, + OrderedItem = new Event + { + // Note this should always be driven from the database, with new FacilityOpportunity's instantiated + Id = RenderOpportunityId(new EventOpportunity + { + OpportunityType = OpportunityType.Event, + EventId = @event.Id, + + }), + Name = @event.Title, + Url = new Uri("https://example.com/events/" + @event.Id), + Location = new Place + { + Name = "Fake event", + Geo = new GeoCoordinates + { + Latitude = @event.LocationLat, + Longitude = @event.LocationLng, + } + }, + Activity = new List + { + new Concept + { + Id = new Uri("https://openactive.io/activity-list#6bdea630-ad22-4e58-98a3-bca26ee3f1da"), + PrefLabel = "Rave Fitness", + InScheme = new Uri("https://openactive.io/activity-list") + } + }, + StartDate = (DateTimeOffset)@event.Start, + EndDate = (DateTimeOffset)@event.End, + MaximumAttendeeCapacity = @event.TotalSpaces, + // Exclude current Order from the returned lease count + RemainingAttendeeCapacity = @event.RemainingSpaces - remainingUsesIncludingOtherLeases + }, + Attendee = orderItemContext.RequestOrderItem.Attendee, + AttendeeDetailsRequired = @event.RequiresAttendeeValidation + ? new List + { + PropertyEnumeration.GivenName, + PropertyEnumeration.FamilyName, + PropertyEnumeration.Email, + PropertyEnumeration.Telephone, + } + : null, + OrderItemIntakeForm = @event.RequiresAdditionalDetails + ? PropertyValueSpecificationHelper.HydrateAdditionalDetailsIntoPropertyValueSpecifications(@event.RequiredAdditionalDetails) + : null, + OrderItemIntakeFormResponse = orderItemContext.RequestOrderItem.OrderItemIntakeFormResponse, + }, + SellerId = _appSettings.FeatureFlags.SingleSeller ? new SellerIdComponents() : new SellerIdComponents { SellerIdLong = @event.SellerId }, + @event.RequiresApproval, + BookedOrderItemInfo = bookedOrderItemInfo, + }; + }); + + + // Add the response OrderItems to the relevant contexts (note that the context must be updated within this method) + foreach (var (item, ctx) in query.Zip(orderItemContexts, (item, ctx) => (item, ctx))) + { + if (item == null) + { + ctx.SetResponseOrderItemAsSkeleton(); + ctx.AddError(new UnknownOpportunityError()); + } + else + { + ctx.SetResponseOrderItem(item.OrderItem, item.SellerId, flowContext); + + if (item.BookedOrderItemInfo != null) + { + BookedOrderItemHelper.AddPropertiesToBookedOrderItem(ctx, item.BookedOrderItemInfo); + } + + if (item.RequiresApproval) + ctx.SetRequiresApproval(); + + if (item.OrderItem.OrderedItem.Object.RemainingAttendeeCapacity == 0) + ctx.AddError(new OpportunityIsFullError()); + + // Add validation errors to the OrderItem if either attendee details or additional details are required but not provided + var validationErrors = ctx.ValidateDetails(flowContext.Stage); + if (validationErrors.Count > 0) + ctx.AddErrors(validationErrors); + } + } + } + + protected async override ValueTask LeaseOrderItems( + Lease lease, List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) + { + // Check that there are no conflicts between the supplied opportunities + // Also take into account spaces requested across OrderItems against total spaces in each opportunity + + foreach (var ctxGroup in orderItemContexts.GroupBy(x => x.RequestBookableOpportunityOfferId)) + { + // Check that the Opportunity ID and type are as expected for the store + if (ctxGroup.Key.OpportunityType != OpportunityType.Event || !ctxGroup.Key.EventId.HasValue) + { + foreach (var ctx in ctxGroup) + { + ctx.AddError(new OpportunityIntractableError(), "Opportunity ID and type are as not expected for the store. Likely a configuration issue with the Booking System."); + } + } + else + { + // Attempt to lease for those with the same IDs, which is atomic + var (result, capacityErrors, capacityLeaseErrors) = FakeDatabase.LeaseOrderItemsForEvent( + databaseTransaction.FakeDatabaseTransaction, + flowContext.OrderId.ClientId, + flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, + flowContext.OrderId.uuid, + ctxGroup.Key.EventId.Value, + ctxGroup.Count()); + + switch (result) + { + case ReserveOrderItemsResult.Success: + // Do nothing, no errors to add + break; + case ReserveOrderItemsResult.SellerIdMismatch: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new SellerMismatchError(), "An OrderItem SellerID did not match"); + } + break; + case ReserveOrderItemsResult.OpportunityNotFound: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new UnableToProcessOrderItemError(), "Opportunity not found"); + } + break; + case ReserveOrderItemsResult.OpportunityOfferPairNotBookable: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new OpportunityOfferPairNotBookableError(), "Opportunity not bookable"); + } + break; + case ReserveOrderItemsResult.NotEnoughCapacity: + var contexts = ctxGroup.ToArray(); + for (var i = contexts.Length - 1; i >= 0; i--) + { + var ctx = contexts[i]; + if (capacityErrors > 0) + { + ctx.AddError(new OpportunityHasInsufficientCapacityError()); + capacityErrors--; + } + else if (capacityLeaseErrors > 0) + { + ctx.AddError(new OpportunityCapacityIsReservedByLeaseError()); + capacityLeaseErrors--; + } + } + + break; + default: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new OpportunityIntractableError(), "OrderItem could not be leased for unexpected reasons."); + } + break; + } + } + } + } + + //TODO: This should reuse code of LeaseOrderItem + protected async override ValueTask BookOrderItems(List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) + { + // Check that there are no conflicts between the supplied opportunities + // Also take into account spaces requested across OrderItems against total spaces in each opportunity + foreach (var ctxGroup in orderItemContexts.GroupBy(x => x.RequestBookableOpportunityOfferId)) + { + // Check that the Opportunity ID and type are as expected for the store + if (ctxGroup.Key.OpportunityType != OpportunityType.Event || !ctxGroup.Key.EventId.HasValue) + { + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity ID and type are as not expected for the EventStore, during booking"); + } + + // Attempt to book for those with the same IDs, which is atomic + var (result, bookedOrderItemInfos) = FakeDatabase.BookOrderItemsForEvent( + databaseTransaction.FakeDatabaseTransaction, + flowContext.OrderId.ClientId, + flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, + flowContext.OrderId.uuid, + ctxGroup.Key.EventId.Value, + RenderOpportunityId(ctxGroup.Key), + RenderOfferId(ctxGroup.Key), + ctxGroup.Count(), + false + ); + + switch (result) + { + case ReserveOrderItemsResult.Success: + foreach (var (ctx, bookedOrderItemInfo) in ctxGroup.Zip(bookedOrderItemInfos, (ctx, bookedOrderItemInfo) => (ctx, bookedOrderItemInfo))) + { + // Set OrderItemId and access properties for each orderItemContext + ctx.SetOrderItemId(flowContext, bookedOrderItemInfo.OrderItemId); + BookedOrderItemHelper.AddPropertiesToBookedOrderItem(ctx, bookedOrderItemInfo); + } + break; + case ReserveOrderItemsResult.SellerIdMismatch: + throw new OpenBookingException(new SellerMismatchError(), "An OrderItem SellerID did not match"); + case ReserveOrderItemsResult.OpportunityNotFound: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity not found"); + case ReserveOrderItemsResult.NotEnoughCapacity: + throw new OpenBookingException(new OpportunityHasInsufficientCapacityError()); + case ReserveOrderItemsResult.OpportunityOfferPairNotBookable: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity and offer pair were not bookable"); + default: + throw new OpenBookingException(new OrderCreationFailedError(), "Booking failed for an unexpected reason"); + } + } + } + + // TODO check logic here, it's just been copied from BookOrderItems. Possibly could remove duplication here. + protected async override ValueTask ProposeOrderItems(List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) + { + // Check that there are no conflicts between the supplied opportunities + // Also take into account spaces requested across OrderItems against total spaces in each opportunity + + foreach (var ctxGroup in orderItemContexts.GroupBy(x => x.RequestBookableOpportunityOfferId)) + { + // Check that the Opportunity ID and type are as expected for the store + if (ctxGroup.Key.OpportunityType != OpportunityType.Event || !ctxGroup.Key.EventId.HasValue) + { + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity ID and type are as not expected for the EventStore, during proposal"); + } + + // Attempt to book for those with the same IDs, which is atomic + var (result, bookedOrderItemInfos) = FakeDatabase.BookOrderItemsForEvent( + databaseTransaction.FakeDatabaseTransaction, + flowContext.OrderId.ClientId, + flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, + flowContext.OrderId.uuid, + ctxGroup.Key.EventId.Value, + RenderOpportunityId(ctxGroup.Key), + RenderOfferId(ctxGroup.Key), + ctxGroup.Count(), + true + ); + + switch (result) + { + case ReserveOrderItemsResult.Success: + foreach (var (ctx, bookedOrderItemInfo) in ctxGroup.Zip(bookedOrderItemInfos, (ctx, bookedOrderItemInfo) => (ctx, bookedOrderItemInfo))) + { + ctx.SetOrderItemId(flowContext, bookedOrderItemInfo.OrderItemId); + } + break; + case ReserveOrderItemsResult.SellerIdMismatch: + throw new OpenBookingException(new SellerMismatchError(), "An OrderItem SellerID did not match"); + case ReserveOrderItemsResult.OpportunityNotFound: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity not found"); + case ReserveOrderItemsResult.NotEnoughCapacity: + throw new OpenBookingException(new OpportunityHasInsufficientCapacityError()); + case ReserveOrderItemsResult.OpportunityOfferPairNotBookable: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity and offer pair were not bookable"); + default: + throw new OpenBookingException(new OrderCreationFailedError(), "Booking failed for an unexpected reason"); + } + } + } + + + } + + + +} diff --git a/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs b/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs index c843a413..af197a8e 100644 --- a/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs +++ b/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs @@ -288,7 +288,7 @@ protected async override Task GetOrderItems(List GetUnitTaxSpecification(BookingFlowContext flowContext, SlotTable slot) - { - switch (flowContext.TaxPayeeRelationship) - { - case TaxPayeeRelationship.BusinessToBusiness when _appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when _appSettings.Payment.TaxCalculationB2C: - return new List - { - new TaxChargeSpecification - { - Name = "VAT at 20%", - Price = slot.Price * (decimal?) 0.2, - PriceCurrency = "GBP", - Rate = (decimal?) 0.2 - } - }; - case TaxPayeeRelationship.BusinessToBusiness when !_appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when !_appSettings.Payment.TaxCalculationB2C: - return null; - default: - throw new ArgumentOutOfRangeException(); - } - } } } diff --git a/Examples/BookingSystem.AspNetCore/Stores/SessionStore.cs b/Examples/BookingSystem.AspNetCore/Stores/SessionStore.cs index fd8b6de1..8ab4fc32 100644 --- a/Examples/BookingSystem.AspNetCore/Stores/SessionStore.cs +++ b/Examples/BookingSystem.AspNetCore/Stores/SessionStore.cs @@ -310,7 +310,7 @@ protected async override Task GetOrderItems(List GetUnitTaxSpecification(BookingFlowContext flowContext, ClassTable classes) - { - switch (flowContext.TaxPayeeRelationship) - { - case TaxPayeeRelationship.BusinessToBusiness when _appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when _appSettings.Payment.TaxCalculationB2C: - return new List - { - new TaxChargeSpecification - { - Name = "VAT at 20%", - Price = classes.Price * (decimal?)0.2, - PriceCurrency = "GBP", - Rate = (decimal?)0.2 - } - }; - case TaxPayeeRelationship.BusinessToBusiness when !_appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when !_appSettings.Payment.TaxCalculationB2C: - return null; - default: - throw new ArgumentOutOfRangeException(); - } - } } } diff --git a/Examples/BookingSystem.AspNetFramework/BookingSystem.AspNetFramework.csproj b/Examples/BookingSystem.AspNetFramework/BookingSystem.AspNetFramework.csproj index 83ee6503..49a1f466 100644 --- a/Examples/BookingSystem.AspNetFramework/BookingSystem.AspNetFramework.csproj +++ b/Examples/BookingSystem.AspNetFramework/BookingSystem.AspNetFramework.csproj @@ -467,6 +467,11 @@ + + + + + diff --git a/Examples/BookingSystem.AspNetFramework/Extensions/FeedGeneratorHelper.cs b/Examples/BookingSystem.AspNetFramework/Extensions/FeedGeneratorHelper.cs new file mode 100644 index 00000000..7c90520e --- /dev/null +++ b/Examples/BookingSystem.AspNetFramework/Extensions/FeedGeneratorHelper.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.Server.NET.OpenBookingHelper; + +namespace BookingSystem +{ + public static class FeedGeneratorHelper + { + public static List OpenBookingFlowRequirement(bool requiresApproval, bool requiresAttendeeValidation, bool requiresAdditionalDetails, bool allowsProposalAmendment) + { + List openBookingFlowRequirement = null; + + if (requiresApproval) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); + } + + if (requiresAttendeeValidation) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + } + + if (requiresAdditionalDetails) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); + } + + if (allowsProposalAmendment) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); + } + return openBookingFlowRequirement; + } + + public static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) + { + switch (attendanceMode) + { + case AttendanceMode.Offline: + return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; + case AttendanceMode.Online: + return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; + case AttendanceMode.Mixed: + return EventAttendanceModeEnumeration.MixedEventAttendanceMode; + default: + throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); + } + } + } +} diff --git a/Examples/BookingSystem.AspNetFramework/Extensions/StoreHelper.cs b/Examples/BookingSystem.AspNetFramework/Extensions/StoreHelper.cs new file mode 100644 index 00000000..90edae1e --- /dev/null +++ b/Examples/BookingSystem.AspNetFramework/Extensions/StoreHelper.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.Server.NET.OpenBookingHelper; + +namespace BookingSystem +{ + public static class StoreHelper + { + public static List GetUnitTaxSpecification(BookingFlowContext flowContext, AppSettings appSettings, decimal? price) + { + switch (flowContext.TaxPayeeRelationship) + { + case TaxPayeeRelationship.BusinessToBusiness when appSettings.Payment.TaxCalculationB2B: + case TaxPayeeRelationship.BusinessToConsumer when appSettings.Payment.TaxCalculationB2C: + return new List + { + new TaxChargeSpecification + { + Name = "VAT at 20%", + Price = price * (decimal?)0.2, + PriceCurrency = "GBP", + Rate = (decimal?)0.2 + } + }; + case TaxPayeeRelationship.BusinessToBusiness when !appSettings.Payment.TaxCalculationB2B: + case TaxPayeeRelationship.BusinessToConsumer when !appSettings.Payment.TaxCalculationB2C: + return null; + default: + throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/Examples/BookingSystem.AspNetFramework/Feeds/EventsFeed.cs b/Examples/BookingSystem.AspNetFramework/Feeds/EventsFeed.cs new file mode 100644 index 00000000..fa1ee483 --- /dev/null +++ b/Examples/BookingSystem.AspNetFramework/Feeds/EventsFeed.cs @@ -0,0 +1,172 @@ +using OpenActive.DatasetSite.NET; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.NET.Rpde.Version1; +using OpenActive.Server.NET.OpenBookingHelper; +using ServiceStack.OrmLite; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BookingSystem +{ + public class AcmeEventRpdeGenerator : RpdeFeedModifiedTimestampAndIdLong + { + private readonly bool _useSingleSellerMode; + + // Example constructor that can set state from EngineConfig + public AcmeEventRpdeGenerator(bool useSingleSellerMode) + { + this._useSingleSellerMode = useSingleSellerMode; + } + + protected async override Task>> GetRpdeItems(long? afterTimestamp, long? afterId) + { + using (var db = FakeBookingSystem.Database.Mem.Database.Open()) + { + var q = db.From() + .Join() + .OrderBy(x => x.Modified) + .ThenBy(x => x.Id) + .Where(x => x.IsEvent) // Filters for Events only + .Where(x => !afterTimestamp.HasValue && !afterId.HasValue || + x.Modified > afterTimestamp || + x.Modified == afterTimestamp && x.Id > afterId && + x.Modified < (DateTimeOffset.UtcNow - new TimeSpan(0, 0, 2)).UtcTicks) + .Take(RpdePageSize); + + var query = db + .SelectMulti(q) + .Select(result => new RpdeItem + { + Kind = RpdeKind.Event, + Id = result.Item1.Id, + Modified = result.Item1.Modified, + State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = result.Item1.Deleted ? null : new Event + { + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) + Id = RenderOpportunityId(new EventOpportunity + { + OpportunityType = OpportunityType.Event, + EventId = result.Item1.Id, + }), + Name = result.Item1.Title, + EventAttendanceMode = FeedGeneratorHelper.MapAttendanceMode(result.Item1.AttendanceMode), + Organizer = _useSingleSellerMode ? new Organization + { + Id = RenderSingleSellerId(), + Name = "Test Seller", + TaxMode = TaxMode.TaxGross, + TermsOfService = new List + { + new PrivacyPolicy + { + Name = "Privacy Policy", + Url = new Uri("https://example.com/privacy.html"), + RequiresExplicitConsent = false + } + }, + IsOpenBookingAllowed = true, + } : result.Item2.IsIndividual ? (ILegalEntity)new Person + { + Id = RenderSellerId(new SellerIdComponents { SellerIdLong = result.Item2.Id }), + Name = result.Item2.Name, + TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + IsOpenBookingAllowed = true, + } : (ILegalEntity)new Organization + { + Id = RenderSellerId(new SellerIdComponents { SellerIdLong = result.Item2.Id }), + Name = result.Item2.Name, + TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + TermsOfService = new List + { + new PrivacyPolicy + { + Name = "Privacy Policy", + Url = new Uri("https://example.com/privacy.html"), + RequiresExplicitConsent = false + } + }, + IsOpenBookingAllowed = true, + }, + Offers = new List { new Offer + { + Id = RenderOfferId(new EventOpportunity + { + OpportunityType = OpportunityType.Event, + EventId = result.Item1.Id, + OfferId = 0 + }), + Price = result.Item1.Price, + PriceCurrency = "GBP", + OpenBookingFlowRequirement = FeedGeneratorHelper.OpenBookingFlowRequirement( + result.Item1.RequiresApproval, + result.Item1.RequiresAttendeeValidation, + result.Item1.RequiresAdditionalDetails, + result.Item1.AllowsProposalAmendment), + ValidFromBeforeStartDate = result.Item1.ValidFromBeforeStartDate, + LatestCancellationBeforeStartDate = result.Item1.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = result.Item1.Prepayment.Convert(), + AllowCustomerCancellationFullRefund = result.Item1.AllowCustomerCancellationFullRefund + } + }, + Location = result.Item1.AttendanceMode == AttendanceMode.Online ? null : new Place + { + Name = "Fake Pond", + Address = new PostalAddress + { + StreetAddress = "1 Fake Park", + AddressLocality = "Another town", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1AA", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = result.Item1.LocationLat, + Longitude = result.Item1.LocationLng, + } + }, + AffiliatedLocation = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : new Place + { + Name = "Fake Pond", + Address = new PostalAddress + { + StreetAddress = "1 Fake Park", + AddressLocality = "Another town", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1AA", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = result.Item1.LocationLat, + Longitude = result.Item1.LocationLng, + } + }, + Url = new Uri("https://www.example.com/a-session-age"), + Activity = new List + { + new Concept + { + Id = new Uri("https://openactive.io/activity-list#c07d63a0-8eb9-4602-8bcc-23be6deb8f83"), + PrefLabel = "Jet Skiing", + InScheme = new Uri("https://openactive.io/activity-list") + } + }, + StartDate = (DateTimeOffset)result.Item1.Start, + EndDate = (DateTimeOffset)result.Item1.End, + Duration = result.Item1.End - result.Item1.Start, + RemainingAttendeeCapacity = result.Item1.RemainingSpaces - result.Item1.LeasedSpaces, + MaximumAttendeeCapacity = result.Item1.TotalSpaces + } + }); + return query.ToList(); + } + } + } +} diff --git a/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs b/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs index a53060f2..8f3cc876 100644 --- a/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs +++ b/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs @@ -175,7 +175,11 @@ protected async override Task>> GetRpdeItems(long? afterTime }), Price = x.Price, PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(x), + OpenBookingFlowRequirement = FeedGeneratorHelper.OpenBookingFlowRequirement( + x.RequiresApproval, + x.RequiresAttendeeValidation, + x.RequiresAdditionalDetails, + x.AllowsProposalAmendment), ValidFromBeforeStartDate = x.ValidFromBeforeStartDate, LatestCancellationBeforeStartDate = x.LatestCancellationBeforeStartDate, OpenBookingPrepayment = x.Prepayment.Convert(), @@ -188,35 +192,5 @@ protected async override Task>> GetRpdeItems(long? afterTime return query.ToList(); } } - - private static List OpenBookingFlowRequirement(SlotTable slot) - { - List openBookingFlowRequirement = null; - - if (slot.RequiresApproval) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); - } - - if (slot.RequiresAttendeeValidation) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); - } - - if (slot.RequiresAdditionalDetails) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); - } - - if (slot.AllowsProposalAmendment) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); - } - return openBookingFlowRequirement; - } } } \ No newline at end of file diff --git a/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs b/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs index f9d0e5cf..56b77185 100644 --- a/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs +++ b/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs @@ -80,6 +80,7 @@ protected async override Task>> GetRpdeItems(long? .Join() .OrderBy(x => x.Modified) .ThenBy(x => x.Id) + .Where(x => !x.IsEvent) // Filters for SessionSeries only (as opposed to Events) .Where(x => !afterTimestamp.HasValue && !afterId.HasValue || x.Modified > afterTimestamp || x.Modified == afterTimestamp && x.Id > afterId && @@ -105,7 +106,7 @@ protected async override Task>> GetRpdeItems(long? SessionSeriesId = result.Item1.Id }), Name = result.Item1.Title, - EventAttendanceMode = MapAttendanceMode(result.Item1.AttendanceMode), + EventAttendanceMode = FeedGeneratorHelper.MapAttendanceMode(result.Item1.AttendanceMode), Organizer = _useSingleSellerMode ? new Organization { Id = RenderSingleSellerId(), @@ -153,7 +154,11 @@ protected async override Task>> GetRpdeItems(long? }), Price = result.Item1.Price, PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(result.Item1), + OpenBookingFlowRequirement = FeedGeneratorHelper.OpenBookingFlowRequirement( + result.Item1.RequiresApproval, + result.Item1.RequiresAttendeeValidation, + result.Item1.RequiresAdditionalDetails, + result.Item1.AllowsProposalAmendment), ValidFromBeforeStartDate = result.Item1.ValidFromBeforeStartDate, LatestCancellationBeforeStartDate = result.Item1.LatestCancellationBeforeStartDate, OpenBookingPrepayment = result.Item1.Prepayment.Convert(), @@ -203,7 +208,8 @@ protected async override Task>> GetRpdeItems(long? PrefLabel = "Jet Skiing", InScheme = new Uri("https://openactive.io/activity-list") } - } + }, + EventSchedule = HydatePartialSchedules(result.Item1.PartialScheduleDay, result.Item1.PartialScheduleTime, result.Item1.PartialScheduleDuration), } }); ; @@ -211,49 +217,21 @@ protected async override Task>> GetRpdeItems(long? } } - private static List OpenBookingFlowRequirement(ClassTable @class) + private List HydatePartialSchedules(DayOfWeek partialScheduleDayOfWeek, DateTimeOffset partialScheduleStartTime, TimeSpan partialScheduleDuration) { - List openBookingFlowRequirement = null; - - if (@class.RequiresApproval) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); - } - - if (@class.RequiresAttendeeValidation) + var schedules = new List() { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); - } - - if (@class.RequiresAdditionalDetails) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); - } - - if (@class.AllowsProposalAmendment) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); - } - return openBookingFlowRequirement; - } + new PartialSchedule() + { + ByDay = new List() { $"https://schema.org/{partialScheduleDayOfWeek}" }, + StartTime = partialScheduleStartTime, + Duration = partialScheduleDuration, + EndTime = partialScheduleStartTime.Add(partialScheduleDuration), + ScheduleTimezone = "Europe/London", + } + }; - private static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) - { - switch (attendanceMode) - { - case AttendanceMode.Offline: - return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; - case AttendanceMode.Online: - return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; - case AttendanceMode.Mixed: - return EventAttendanceModeEnumeration.MixedEventAttendanceMode; - default: - throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); - } + return schedules; } } } diff --git a/Examples/BookingSystem.AspNetFramework/IdComponents/EventOpportunity.cs b/Examples/BookingSystem.AspNetFramework/IdComponents/EventOpportunity.cs new file mode 100644 index 00000000..b6602e16 --- /dev/null +++ b/Examples/BookingSystem.AspNetFramework/IdComponents/EventOpportunity.cs @@ -0,0 +1,48 @@ +using OpenActive.DatasetSite.NET; +using OpenActive.Server.NET.OpenBookingHelper; + +namespace BookingSystem +{ + /// + /// These classes must be created by the booking system, the below are some simple examples. + /// These should be created alongside the IdConfiguration and OpenDataFeeds settings, as the two work together + /// + /// They can be completely customised to match the preferred ID structure of the booking system + /// + /// There is a choice of `string`, `long?` and `Uri` available for each component of the ID + /// + public class EventOpportunity : IBookableIdComponents + { + public OpportunityType? OpportunityType { get; set; } + public long? EventId { get; set; } + public long? OfferId { get; set; } + + public override bool Equals(object obj) + { + var other = obj as EventOpportunity; + if (ReferenceEquals(other, null)) + return false; + + return EventId == other.EventId && + OfferId == other.OfferId; + } + + public override int GetHashCode() + { + unchecked + { + // ReSharper disable NonReadonlyMemberInGetHashCode + var hashCode = OpportunityType.GetHashCode(); + hashCode = (hashCode * 397) ^ EventId.GetHashCode(); + hashCode = (hashCode * 397) ^ OfferId.GetHashCode(); + // ReSharper enable NonReadonlyMemberInGetHashCode + return hashCode; + } + } + + public static bool operator ==(EventOpportunity x, EventOpportunity y) => x != null && x.Equals(y); + + public static bool operator !=(EventOpportunity x, EventOpportunity y) => !(x == y); + + } +} diff --git a/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs b/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs index 43446489..38a8dba8 100644 --- a/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs +++ b/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs @@ -53,7 +53,19 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting OpportunityType = OpportunityType.FacilityUse, AssignedFeed = OpportunityType.FacilityUse, OpportunityIdTemplate = "{+BaseUrl}/facility-uses/{FacilityUseId}" - })/*, + }), + new BookablePairIdTemplate( + // Opportunity + new OpportunityIdConfiguration + { + OpportunityType = OpportunityType.Event, + AssignedFeed = OpportunityType.Event, + OpportunityIdTemplate = "{+BaseUrl}/events/{EventId}", + OfferIdTemplate = "{+BaseUrl}/events/{EventId}#/offers/{OfferId}", + Bookable = true + }) + + /*, new BookablePairIdTemplate( // Opportunity @@ -91,18 +103,7 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting OpportunityUriTemplate = "{+BaseUrl}/courses/{CourseId}", OfferUriTemplate = "{+BaseUrl}/courses/{CourseId}#/offers/{OfferId}", Bookable = true - }), - - new BookablePairIdTemplate( - // Opportunity - new OpportunityIdConfiguration - { - OpportunityType = OpportunityType.Event, - AssignedFeed = OpportunityType.Event, - OpportunityUriTemplate = "{+BaseUrl}/events/{EventId}", - OfferUriTemplate = "{+BaseUrl}/events/{EventId}#/offers/{OfferId}", - Bookable = true - })*/ + }),*/ }, @@ -148,6 +149,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting }, { OpportunityType.FacilityUseSlot, new AcmeFacilityUseSlotRpdeGenerator() + }, + { + OpportunityType.Event, new AcmeEventRpdeGenerator(appSettings.FeatureFlags.SingleSeller) } }, @@ -252,6 +256,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting }, { new FacilityStore(appSettings), new List { OpportunityType.FacilityUseSlot } + }, + { + new EventStore(appSettings), new List { OpportunityType.Event } } }, OrderStore = new AcmeOrderStore(appSettings), diff --git a/Examples/BookingSystem.AspNetFramework/Stores/EventStore.cs b/Examples/BookingSystem.AspNetFramework/Stores/EventStore.cs new file mode 100644 index 00000000..911b7927 --- /dev/null +++ b/Examples/BookingSystem.AspNetFramework/Stores/EventStore.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OpenActive.DatasetSite.NET; +using OpenActive.FakeDatabase.NET; +using OpenActive.NET; +using OpenActive.Server.NET.OpenBookingHelper; +using OpenActive.Server.NET.StoreBooking; +using ServiceStack.OrmLite; +using RequiredStatusType = OpenActive.FakeDatabase.NET.RequiredStatusType; + +namespace BookingSystem +{ + class EventStore : OpportunityStore + { + private readonly AppSettings _appSettings; + + // Example constructor that can set state from EngineConfig. This is not required for an actual implementation. + public EventStore(AppSettings appSettings) + { + _appSettings = appSettings; + } + + Random rnd = new Random(); + + protected async override Task CreateOpportunityWithinTestDataset( + string testDatasetIdentifier, + OpportunityType opportunityType, + TestOpportunityCriteriaEnumeration criteria, + TestOpenBookingFlowEnumeration openBookingFlow, + SellerIdComponents seller) + { + if (!_appSettings.FeatureFlags.SingleSeller && !seller.SellerIdLong.HasValue) + throw new OpenBookingException(new OpenBookingError(), "Seller must have an ID in Multiple Seller Mode"); + + long? sellerId = _appSettings.FeatureFlags.SingleSeller ? null : seller.SellerIdLong; + var requiresApproval = openBookingFlow == TestOpenBookingFlowEnumeration.OpenBookingApprovalFlow; + + switch (opportunityType) + { + case OpportunityType.Event: + int eventId; + switch (criteria) + { + case TestOpportunityCriteriaEnumeration.TestOpportunityBookable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Event", + rnd.Next(2) == 0 ? 0M : 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellable: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFree: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableUsingPayment: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableWithinValidFromBeforeStartDate: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableOutsideValidFromBeforeStartDate: + { + var isValid = criteria == TestOpportunityCriteriaEnumeration.TestOpportunityBookableWithinValidFromBeforeStartDate; + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + $"[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event {(isValid ? "Within" : "Outside")} Window", + 14.99M, + 10, + requiresApproval, + validFromStartDate: isValid); + } + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellableWithinWindow: + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellableOutsideWindow: + { + var isValid = criteria == TestOpportunityCriteriaEnumeration.TestOpportunityBookableCancellableWithinWindow; + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + $"[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event {(isValid ? "Within" : "Outside")} Cancellation Window", + 14.99M, + 10, + requiresApproval, + latestCancellationBeforeStartDate: isValid); + } + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreePrepaymentOptional: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Prepayment Optional", + 10M, + 10, + requiresApproval, + prepayment: RequiredStatusType.Optional); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreePrepaymentUnavailable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Prepayment Unavailable", + 10M, + 10, + requiresApproval, + prepayment: RequiredStatusType.Unavailable); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreePrepaymentRequired: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Prepayment Required", + 10M, + 10, + requiresApproval, + prepayment: RequiredStatusType.Required); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableFree: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Free Event", + 0M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNoSpaces: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Free Event No Spaces", + 14.99M, + 0, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableFiveSpaces: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Free Event Five Spaces", + 14.99M, + 5, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreeTaxNet: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + 2, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Tax Net", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNonFreeTaxGross: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + 1, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event Tax Gross", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableSellerTermsOfService: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + 1, + "[OPEN BOOKING API TEST INTERFACE] Bookable Event With Seller Terms Of Service", + 14.99M, + 10, + requiresApproval); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableAttendeeDetails: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event That Requires Attendee Details", + 10M, + 10, + requiresApproval, + requiresAttendeeValidation: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableAdditionalDetails: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event That Requires Additional Details", + 10M, + 10, + requiresApproval, + requiresAdditionalDetails: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityOnlineBookable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Virtual Event", + 10M, + 10, + requiresApproval, + isOnlineOrMixedAttendanceMode: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityOfflineBookable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Offline Event", + 10M, + 10, + requiresApproval, + isOnlineOrMixedAttendanceMode: false); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableWithNegotiation: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid Event That Allows Proposal Amendment", + 10M, + 10, + requiresApproval, + allowProposalAmendment: true); + break; + case TestOpportunityCriteriaEnumeration.TestOpportunityBookableNotCancellable: + eventId = FakeBookingSystem.Database.AddEvent( + testDatasetIdentifier, + sellerId, + "[OPEN BOOKING API TEST INTERFACE] Bookable Paid That Does Not Allow Full Refund", + 10M, + 10, + requiresApproval, + allowCustomerCancellationFullRefund: false); + break; + default: + throw new OpenBookingException(new OpenBookingError(), "testOpportunityCriteria value not supported"); + } + + return new EventOpportunity + { + OpportunityType = opportunityType, + EventId = eventId + }; + + default: + throw new OpenBookingException(new OpenBookingError(), "Opportunity Type not supported"); + } + } + + protected async override Task DeleteTestDataset(string testDatasetIdentifier) + { + FakeBookingSystem.Database.DeleteTestEventsFromDataset(testDatasetIdentifier); + } + + protected async override Task TriggerTestAction(OpenBookingSimulateAction simulateAction, EventOpportunity idComponents) + { + switch (simulateAction) + { + case ChangeOfLogisticsTimeSimulateAction _: + if (!FakeBookingSystem.Database.UpdateEventStartAndEndTimeByPeriodInMins(idComponents.EventId.Value, 60)) + { + throw new OpenBookingException(new UnknownOpportunityError()); + } + return; + case ChangeOfLogisticsNameSimulateAction _: + if (!FakeBookingSystem.Database.UpdateEventTitle(idComponents.EventId.Value, "Updated Event Title")) + { + throw new OpenBookingException(new UnknownOpportunityError()); + } + return; + case ChangeOfLogisticsLocationSimulateAction _: + if (!FakeBookingSystem.Database.UpdateEventLocationLatLng(idComponents.EventId.Value, 0.2m, 0.3m)) + { + throw new OpenBookingException(new UnknownOpportunityError()); + } + return; + default: + throw new NotImplementedException(); + } + + } + + // Similar to the RPDE logic, this needs to render and return an new hypothetical OrderItem from the database based on the supplied opportunity IDs + protected async override Task GetOrderItems(List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext) + { + // Note the implementation of this method must also check that this OrderItem is from the Seller specified by context.SellerIdComponents (this is not required if using a Single Seller) + + // Additionally this method must check that there are enough spaces in each entry + + // Response OrderItems must be updated into supplied orderItemContexts (including duplicates for multi-party booking) + + var query = orderItemContexts.Select((orderItemContext) => + { + var getOccurrenceInfoResult = FakeBookingSystem.Database.GetEventAndBookedOrderItemInfoByEventId(flowContext.OrderId.uuid, orderItemContext.RequestBookableOpportunityOfferId.EventId); + var (hasFoundOccurrence, @event, bookedOrderItemInfo) = getOccurrenceInfoResult; + if (hasFoundOccurrence == false) + { + return null; + } + var remainingUsesIncludingOtherLeases = FakeBookingSystem.Database.GetNumberOfOtherLeasesForEvent(flowContext.OrderId.uuid, orderItemContext.RequestBookableOpportunityOfferId.EventId); + + return new + { + OrderItem = new OrderItem + { + // TODO: The static example below should come from the database (which doesn't currently support tax) + UnitTaxSpecification = StoreHelper.GetUnitTaxSpecification(flowContext, _appSettings, @event.Price), + AcceptedOffer = new Offer + { + // Note this should always use RenderOfferId with the supplied SessionFacilityOpportunity, to take into account inheritance and OfferType + Id = RenderOfferId(orderItemContext.RequestBookableOpportunityOfferId), + Price = @event.Price, + PriceCurrency = "GBP", + LatestCancellationBeforeStartDate = @event.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = @event.Prepayment.Convert(), + ValidFromBeforeStartDate = @event.ValidFromBeforeStartDate, + AllowCustomerCancellationFullRefund = @event.AllowCustomerCancellationFullRefund, + }, + OrderedItem = new Event + { + // Note this should always be driven from the database, with new FacilityOpportunity's instantiated + Id = RenderOpportunityId(new EventOpportunity + { + OpportunityType = OpportunityType.Event, + EventId = @event.Id, + + }), + Name = @event.Title, + Url = new Uri("https://example.com/events/" + @event.Id), + Location = new Place + { + Name = "Fake event", + Geo = new GeoCoordinates + { + Latitude = @event.LocationLat, + Longitude = @event.LocationLng, + } + }, + Activity = new List + { + new Concept + { + Id = new Uri("https://openactive.io/activity-list#6bdea630-ad22-4e58-98a3-bca26ee3f1da"), + PrefLabel = "Rave Fitness", + InScheme = new Uri("https://openactive.io/activity-list") + } + }, + StartDate = (DateTimeOffset)@event.Start, + EndDate = (DateTimeOffset)@event.End, + MaximumAttendeeCapacity = @event.TotalSpaces, + // Exclude current Order from the returned lease count + RemainingAttendeeCapacity = @event.RemainingSpaces - remainingUsesIncludingOtherLeases + }, + Attendee = orderItemContext.RequestOrderItem.Attendee, + AttendeeDetailsRequired = @event.RequiresAttendeeValidation + ? new List + { + PropertyEnumeration.GivenName, + PropertyEnumeration.FamilyName, + PropertyEnumeration.Email, + PropertyEnumeration.Telephone, + } + : null, + OrderItemIntakeForm = @event.RequiresAdditionalDetails + ? PropertyValueSpecificationHelper.HydrateAdditionalDetailsIntoPropertyValueSpecifications(@event.RequiredAdditionalDetails) + : null, + OrderItemIntakeFormResponse = orderItemContext.RequestOrderItem.OrderItemIntakeFormResponse, + }, + SellerId = _appSettings.FeatureFlags.SingleSeller ? new SellerIdComponents() : new SellerIdComponents { SellerIdLong = @event.SellerId }, + @event.RequiresApproval, + BookedOrderItemInfo = bookedOrderItemInfo, + }; + }); + + + // Add the response OrderItems to the relevant contexts (note that the context must be updated within this method) + foreach (var (item, ctx) in query.Zip(orderItemContexts, (item, ctx) => (item, ctx))) + { + if (item == null) + { + ctx.SetResponseOrderItemAsSkeleton(); + ctx.AddError(new UnknownOpportunityError()); + } + else + { + ctx.SetResponseOrderItem(item.OrderItem, item.SellerId, flowContext); + + if (item.BookedOrderItemInfo != null) + { + BookedOrderItemHelper.AddPropertiesToBookedOrderItem(ctx, item.BookedOrderItemInfo); + } + + if (item.RequiresApproval) + ctx.SetRequiresApproval(); + + if (item.OrderItem.OrderedItem.Object.RemainingAttendeeCapacity == 0) + ctx.AddError(new OpportunityIsFullError()); + + // Add validation errors to the OrderItem if either attendee details or additional details are required but not provided + var validationErrors = ctx.ValidateDetails(flowContext.Stage); + if (validationErrors.Count > 0) + ctx.AddErrors(validationErrors); + } + } + } + + protected async override ValueTask LeaseOrderItems( + Lease lease, List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) + { + // Check that there are no conflicts between the supplied opportunities + // Also take into account spaces requested across OrderItems against total spaces in each opportunity + + foreach (var ctxGroup in orderItemContexts.GroupBy(x => x.RequestBookableOpportunityOfferId)) + { + // Check that the Opportunity ID and type are as expected for the store + if (ctxGroup.Key.OpportunityType != OpportunityType.Event || !ctxGroup.Key.EventId.HasValue) + { + foreach (var ctx in ctxGroup) + { + ctx.AddError(new OpportunityIntractableError(), "Opportunity ID and type are as not expected for the store. Likely a configuration issue with the Booking System."); + } + } + else + { + // Attempt to lease for those with the same IDs, which is atomic + var (result, capacityErrors, capacityLeaseErrors) = FakeDatabase.LeaseOrderItemsForEvent( + databaseTransaction.FakeDatabaseTransaction, + flowContext.OrderId.ClientId, + flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, + flowContext.OrderId.uuid, + ctxGroup.Key.EventId.Value, + ctxGroup.Count()); + + switch (result) + { + case ReserveOrderItemsResult.Success: + // Do nothing, no errors to add + break; + case ReserveOrderItemsResult.SellerIdMismatch: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new SellerMismatchError(), "An OrderItem SellerID did not match"); + } + break; + case ReserveOrderItemsResult.OpportunityNotFound: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new UnableToProcessOrderItemError(), "Opportunity not found"); + } + break; + case ReserveOrderItemsResult.OpportunityOfferPairNotBookable: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new OpportunityOfferPairNotBookableError(), "Opportunity not bookable"); + } + break; + case ReserveOrderItemsResult.NotEnoughCapacity: + var contexts = ctxGroup.ToArray(); + for (var i = contexts.Length - 1; i >= 0; i--) + { + var ctx = contexts[i]; + if (capacityErrors > 0) + { + ctx.AddError(new OpportunityHasInsufficientCapacityError()); + capacityErrors--; + } + else if (capacityLeaseErrors > 0) + { + ctx.AddError(new OpportunityCapacityIsReservedByLeaseError()); + capacityLeaseErrors--; + } + } + + break; + default: + foreach (var ctx in ctxGroup) + { + ctx.AddError(new OpportunityIntractableError(), "OrderItem could not be leased for unexpected reasons."); + } + break; + } + } + } + } + + //TODO: This should reuse code of LeaseOrderItem + protected async override ValueTask BookOrderItems(List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) + { + // Check that there are no conflicts between the supplied opportunities + // Also take into account spaces requested across OrderItems against total spaces in each opportunity + foreach (var ctxGroup in orderItemContexts.GroupBy(x => x.RequestBookableOpportunityOfferId)) + { + // Check that the Opportunity ID and type are as expected for the store + if (ctxGroup.Key.OpportunityType != OpportunityType.Event || !ctxGroup.Key.EventId.HasValue) + { + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity ID and type are as not expected for the EventStore, during booking"); + } + + // Attempt to book for those with the same IDs, which is atomic + var (result, bookedOrderItemInfos) = FakeDatabase.BookOrderItemsForEvent( + databaseTransaction.FakeDatabaseTransaction, + flowContext.OrderId.ClientId, + flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, + flowContext.OrderId.uuid, + ctxGroup.Key.EventId.Value, + RenderOpportunityId(ctxGroup.Key), + RenderOfferId(ctxGroup.Key), + ctxGroup.Count(), + false + ); + + switch (result) + { + case ReserveOrderItemsResult.Success: + foreach (var (ctx, bookedOrderItemInfo) in ctxGroup.Zip(bookedOrderItemInfos, (ctx, bookedOrderItemInfo) => (ctx, bookedOrderItemInfo))) + { + // Set OrderItemId and access properties for each orderItemContext + ctx.SetOrderItemId(flowContext, bookedOrderItemInfo.OrderItemId); + BookedOrderItemHelper.AddPropertiesToBookedOrderItem(ctx, bookedOrderItemInfo); + } + break; + case ReserveOrderItemsResult.SellerIdMismatch: + throw new OpenBookingException(new SellerMismatchError(), "An OrderItem SellerID did not match"); + case ReserveOrderItemsResult.OpportunityNotFound: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity not found"); + case ReserveOrderItemsResult.NotEnoughCapacity: + throw new OpenBookingException(new OpportunityHasInsufficientCapacityError()); + case ReserveOrderItemsResult.OpportunityOfferPairNotBookable: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity and offer pair were not bookable"); + default: + throw new OpenBookingException(new OrderCreationFailedError(), "Booking failed for an unexpected reason"); + } + } + } + + // TODO check logic here, it's just been copied from BookOrderItems. Possibly could remove duplication here. + protected async override ValueTask ProposeOrderItems(List> orderItemContexts, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) + { + // Check that there are no conflicts between the supplied opportunities + // Also take into account spaces requested across OrderItems against total spaces in each opportunity + + foreach (var ctxGroup in orderItemContexts.GroupBy(x => x.RequestBookableOpportunityOfferId)) + { + // Check that the Opportunity ID and type are as expected for the store + if (ctxGroup.Key.OpportunityType != OpportunityType.Event || !ctxGroup.Key.EventId.HasValue) + { + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity ID and type are as not expected for the EventStore, during proposal"); + } + + // Attempt to book for those with the same IDs, which is atomic + var (result, bookedOrderItemInfos) = FakeDatabase.BookOrderItemsForEvent( + databaseTransaction.FakeDatabaseTransaction, + flowContext.OrderId.ClientId, + flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, + flowContext.OrderId.uuid, + ctxGroup.Key.EventId.Value, + RenderOpportunityId(ctxGroup.Key), + RenderOfferId(ctxGroup.Key), + ctxGroup.Count(), + true + ); + + switch (result) + { + case ReserveOrderItemsResult.Success: + foreach (var (ctx, bookedOrderItemInfo) in ctxGroup.Zip(bookedOrderItemInfos, (ctx, bookedOrderItemInfo) => (ctx, bookedOrderItemInfo))) + { + ctx.SetOrderItemId(flowContext, bookedOrderItemInfo.OrderItemId); + } + break; + case ReserveOrderItemsResult.SellerIdMismatch: + throw new OpenBookingException(new SellerMismatchError(), "An OrderItem SellerID did not match"); + case ReserveOrderItemsResult.OpportunityNotFound: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity not found"); + case ReserveOrderItemsResult.NotEnoughCapacity: + throw new OpenBookingException(new OpportunityHasInsufficientCapacityError()); + case ReserveOrderItemsResult.OpportunityOfferPairNotBookable: + throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity and offer pair were not bookable"); + default: + throw new OpenBookingException(new OrderCreationFailedError(), "Booking failed for an unexpected reason"); + } + } + } + + + } + + + +} diff --git a/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs b/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs index c843a413..af197a8e 100644 --- a/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs +++ b/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs @@ -288,7 +288,7 @@ protected async override Task GetOrderItems(List GetUnitTaxSpecification(BookingFlowContext flowContext, SlotTable slot) - { - switch (flowContext.TaxPayeeRelationship) - { - case TaxPayeeRelationship.BusinessToBusiness when _appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when _appSettings.Payment.TaxCalculationB2C: - return new List - { - new TaxChargeSpecification - { - Name = "VAT at 20%", - Price = slot.Price * (decimal?) 0.2, - PriceCurrency = "GBP", - Rate = (decimal?) 0.2 - } - }; - case TaxPayeeRelationship.BusinessToBusiness when !_appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when !_appSettings.Payment.TaxCalculationB2C: - return null; - default: - throw new ArgumentOutOfRangeException(); - } - } } } diff --git a/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs b/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs index fd8b6de1..8ab4fc32 100644 --- a/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs +++ b/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs @@ -310,7 +310,7 @@ protected async override Task GetOrderItems(List GetUnitTaxSpecification(BookingFlowContext flowContext, ClassTable classes) - { - switch (flowContext.TaxPayeeRelationship) - { - case TaxPayeeRelationship.BusinessToBusiness when _appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when _appSettings.Payment.TaxCalculationB2C: - return new List - { - new TaxChargeSpecification - { - Name = "VAT at 20%", - Price = classes.Price * (decimal?)0.2, - PriceCurrency = "GBP", - Rate = (decimal?)0.2 - } - }; - case TaxPayeeRelationship.BusinessToBusiness when !_appSettings.Payment.TaxCalculationB2B: - case TaxPayeeRelationship.BusinessToConsumer when !_appSettings.Payment.TaxCalculationB2C: - return null; - default: - throw new ArgumentOutOfRangeException(); - } - } } } diff --git a/Fakes/OpenActive.FakeDatabase.NET/DatabaseTables.cs b/Fakes/OpenActive.FakeDatabase.NET/DatabaseTables.cs index bb8c7a99..4eeea43f 100644 --- a/Fakes/OpenActive.FakeDatabase.NET/DatabaseTables.cs +++ b/Fakes/OpenActive.FakeDatabase.NET/DatabaseTables.cs @@ -52,6 +52,18 @@ public class ClassTable : Table public decimal LocationLng { get; set; } public AttendanceMode AttendanceMode { get; set; } public bool AllowsProposalAmendment { get; set; } + public DayOfWeek PartialScheduleDay { get; set; } + public DateTime PartialScheduleTime { get; set; } + public TimeSpan PartialScheduleDuration { get; set; } + + // Due to ORMLite free tier limit, we can only have 10 tables + // So instead of having a separate EventTable, Events are just Classes with time-specific info + public bool IsEvent { get; set; } = false; + public DateTime Start { get; set; } + public DateTime End { get; set; } + public long TotalSpaces { get; set; } + public long LeasedSpaces { get; set; } + public long RemainingSpaces { get; set; } } public class OccurrenceTable : Table @@ -68,7 +80,6 @@ public class OccurrenceTable : Table public long RemainingSpaces { get; set; } } - public class OrderItemsTable : Table { public string ClientId { get; internal set; } @@ -86,6 +97,10 @@ public class OrderItemsTable : Table public SlotTable SlotTable { get; set; } [ForeignKey(typeof(SlotTable), OnDelete = "CASCADE")] public long? SlotId { get; set; } + [Reference] + public ClassTable EventTable { get; set; } + [ForeignKey(typeof(ClassTable), OnDelete = "CASCADE")] + public long? EventId { get; set; } public BookingStatus Status { get; set; } public string CancellationMessage { get; set; } public decimal Price { get; set; } diff --git a/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs b/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs index 52a2f8f1..d1bc2aee 100644 --- a/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs +++ b/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs @@ -176,18 +176,21 @@ public void CleanupExpiredLeases() { var occurrenceIds = new List(); var slotIds = new List(); + var eventIds = new List(); foreach (var order in db.Select(x => x.LeaseExpires < DateTimeOffset.Now)) { // ReSharper disable twice PossibleInvalidOperationException occurrenceIds.AddRange(db.Select(x => x.OrderId == order.OrderId && x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value)); slotIds.AddRange(db.Select(x => x.OrderId == order.OrderId && x.SlotId.HasValue).Select(x => x.SlotId.Value)); + eventIds.AddRange(db.Select(x => x.OrderId == order.OrderId && x.EventId.HasValue).Select(x => x.EventId.Value)); db.Delete(x => x.OrderId == order.OrderId); db.Delete(x => x.OrderId == order.OrderId); } RecalculateSpaces(db, occurrenceIds.Distinct()); RecalculateSlotUses(db, slotIds.Distinct()); + RecalculateEventSpaces(db, eventIds.Distinct()); } } @@ -322,28 +325,73 @@ public bool UpdateFacilityUseLocationLatLng(long slotId, decimal newLat, decimal } /// - /// Update name logistics data for SessionSeries to trigger logistics change notification + /// Update time based logistics data for ScheduledSession to trigger logistics change notification /// - /// + /// + /// + /// + public bool UpdateEventStartAndEndTimeByPeriodInMins(long eventId, int numberOfMins) + { + using (var db = Mem.Database.Open()) + { + var @event = db.Single(x => x.Id == eventId && !x.Deleted); + if (@event == null) + { + return false; + } + + @event.Start.AddMinutes(numberOfMins); + @event.End.AddMinutes(numberOfMins); + @event.Modified = DateTimeOffset.Now.UtcTicks; + db.Update(@event); + return true; + } + } + + /// + /// Update location based logistics data for an Event to trigger logistics change notification + /// + /// + /// + /// + /// + public bool UpdateEventLocationLatLng(long eventId, decimal newLat, decimal newLng) + { + using (var db = Mem.Database.Open()) + { + var @event = db.Single(x => x.Id == eventId && !x.Deleted); + if (@event == null) + { + return false; + } + + @event.LocationLat = newLat; + @event.LocationLng = newLng; + @event.Modified = DateTimeOffset.Now.UtcTicks; + db.Update(@event); + return true; + } + } + + /// + /// Update name logistics data for an Event to trigger logistics change notification + /// + /// /// /// - public bool UpdateClassTitle(long occurrenceId, string newTitle) + public bool UpdateEventTitle(long eventId, string newTitle) { using (var db = Mem.Database.Open()) { - var query = db.From() - .LeftJoin() - .Where(x => x.Id == occurrenceId) - .And(y => !y.Deleted); - var classInstance = db.Select(query).Single(); - if (classInstance == null) + var @event = db.Single(x => x.Id == eventId && !x.Deleted); + if (@event == null) { return false; } - classInstance.Title = newTitle; - classInstance.Modified = DateTimeOffset.Now.UtcTicks; - db.Update(classInstance); + @event.Title = newTitle; + @event.Modified = DateTimeOffset.Now.UtcTicks; + db.Update(@event); return true; } } @@ -401,6 +449,33 @@ public bool UpdateSessionSeriesLocationLatLng(long occurrenceId, decimal newLat, } } + /// + /// Update name logistics data for SessionSeries to trigger logistics change notification + /// + /// + /// + /// + public bool UpdateClassTitle(long occurrenceId, string newTitle) + { + using (var db = Mem.Database.Open()) + { + var query = db.From() + .LeftJoin() + .Where(x => x.Id == occurrenceId) + .And(y => !y.Deleted); + var classInstance = db.Select(query).Single(); + if (classInstance == null) + { + return false; + } + + classInstance.Title = newTitle; + classInstance.Modified = DateTimeOffset.Now.UtcTicks; + db.Update(classInstance); + return true; + } + } + public bool UpdateAccess(string uuid, bool updateAccessPass = false, bool updateAccessCode = false, bool updateAccessChannel = false) { if (!updateAccessPass && !updateAccessCode && !updateAccessChannel) @@ -530,12 +605,14 @@ public void DeleteLease(string clientId, string uuid, long? sellerId) // ReSharper disable twice PossibleInvalidOperationException var occurrenceIds = db.Select(x => x.ClientId == clientId && x.OrderId == uuid && x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value).Distinct(); var slotIds = db.Select(x => x.ClientId == clientId && x.OrderId == uuid && x.SlotId.HasValue).Select(x => x.SlotId.Value).Distinct(); + var eventIds = db.Select(x => x.ClientId == clientId && x.OrderId == uuid && x.EventId.HasValue).Select(x => x.EventId.Value).Distinct(); db.Delete(x => x.ClientId == clientId && x.OrderId == uuid); db.Delete(x => x.ClientId == clientId && x.OrderId == uuid); RecalculateSpaces(db, occurrenceIds); RecalculateSlotUses(db, slotIds); + RecalculateEventSpaces(db, eventIds); } } } @@ -702,6 +779,41 @@ public static bool AddOrder( } } + public (bool, ClassTable, BookedOrderItemInfo) GetEventAndBookedOrderItemInfoByEventId(string uuid, long? eventId) + { + using (var db = Mem.Database.Open()) + { + var @event = db.Single(x => x.Id == eventId); + + var hasFoundOccurrence = false; + if (@event == null) + { + return (hasFoundOccurrence, null, null); + } + + var orderItem = db.Single(x => x.OrderId == uuid && x.EventId == eventId); + var bookedOrderItemInfo = (orderItem != null && orderItem.Status == BookingStatus.Confirmed) ? + new BookedOrderItemInfo + { + OrderItemId = orderItem.Id, + PinCode = orderItem.PinCode, + ImageUrl = orderItem.ImageUrl, + BarCodeText = orderItem.BarCodeText, + MeetingId = orderItem.MeetingId, + MeetingPassword = orderItem.MeetingPassword, + AttendanceMode = @event.AttendanceMode, + } + : null; + + hasFoundOccurrence = true; + return ( + hasFoundOccurrence, + @event, + bookedOrderItemInfo + ); + } + } + public FakeDatabaseDeleteOrderResult DeleteOrder(string clientId, string uuid, long? sellerId) { using (var db = Mem.Database.Open()) @@ -727,10 +839,12 @@ public FakeDatabaseDeleteOrderResult DeleteOrder(string clientId, string uuid, l var occurrenceIds = db.Select(x => x.ClientId == clientId && x.OrderId == order.OrderId && x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value).Distinct(); var slotIds = db.Select(x => x.ClientId == clientId && x.OrderId == uuid && x.SlotId.HasValue).Select(x => x.SlotId.Value).Distinct(); + var eventIds = db.Select(x => x.ClientId == clientId && x.OrderId == uuid && x.EventId.HasValue).Select(x => x.EventId.Value).Distinct(); db.Delete(x => x.ClientId == clientId && x.OrderId == order.OrderId); RecalculateSpaces(db, occurrenceIds); RecalculateSlotUses(db, slotIds); + RecalculateEventSpaces(db, eventIds); return FakeDatabaseDeleteOrderResult.OrderSuccessfullyDeleted; } @@ -828,6 +942,51 @@ public static (ReserveOrderItemsResult, long?, long?) LeaseOrderItemsForFacility return (ReserveOrderItemsResult.Success, null, null); } + public static (ReserveOrderItemsResult, long?, long?) LeaseOrderItemsForEvent(FakeDatabaseTransaction transaction, string clientId, long? sellerId, string uuid, long eventId, long spacesRequested) + { + var db = transaction.DatabaseConnection; + var thisEvent = db.Single(x => x.Id == eventId && !x.Deleted); + + if (thisEvent == null) + return (ReserveOrderItemsResult.OpportunityNotFound, null, null); + + if (sellerId.HasValue && thisEvent.SellerId != sellerId) + return (ReserveOrderItemsResult.SellerIdMismatch, null, null); + + if (thisEvent.ValidFromBeforeStartDate.HasValue && DateTime.Now < thisEvent.Start - thisEvent.ValidFromBeforeStartDate) + return (ReserveOrderItemsResult.OpportunityOfferPairNotBookable, null, null); + + // Remove existing leases + // Note a real implementation would likely maintain existing leases instead of removing and recreating them + db.Delete(x => x.ClientId == clientId && x.OrderId == uuid && x.EventId == eventId); + RecalculateEventSpaces(db, thisEvent); + + // Only lease if all spaces requested are available + if (thisEvent.RemainingSpaces - thisEvent.LeasedSpaces < spacesRequested) + { + var notionalRemainingSpaces = thisEvent.RemainingSpaces - thisEvent.LeasedSpaces; + var totalCapacityErrors = Math.Max(0, spacesRequested - notionalRemainingSpaces); + var capacityErrorsCausedByLeasing = Math.Min(totalCapacityErrors, thisEvent.LeasedSpaces); + return (ReserveOrderItemsResult.NotEnoughCapacity, totalCapacityErrors - capacityErrorsCausedByLeasing, capacityErrorsCausedByLeasing); + } + + for (var i = 0; i < spacesRequested; i++) + { + db.Insert(new OrderItemsTable + { + ClientId = clientId, + Deleted = false, + OrderId = uuid, + EventId = eventId, + Status = BookingStatus.None + }); + } + + // Update number of spaces remaining for the opportunity + RecalculateEventSpaces(db, thisEvent); + return (ReserveOrderItemsResult.Success, null, null); + } + public class BookedOrderItemInfo { public long OrderItemId { get; set; } @@ -982,6 +1141,78 @@ bool proposal return (ReserveOrderItemsResult.Success, bookedOrderItemInfos); } + // TODO this should reuse code of LeaseOrderItemsForEvent + public static (ReserveOrderItemsResult, List) BookOrderItemsForEvent( + FakeDatabaseTransaction transaction, + string clientId, + long? sellerId, + string uuid, + long eventId, + Uri opportunityJsonLdId, + Uri offerJsonLdId, + long numberOfSpaces, + bool proposal + ) + { + var db = transaction.DatabaseConnection; + var thisEvent = db.Single(x => x.Id == eventId && !x.Deleted); + + if (thisEvent == null) + return (ReserveOrderItemsResult.OpportunityNotFound, null); + + if (sellerId.HasValue && thisEvent.SellerId != sellerId) + return (ReserveOrderItemsResult.SellerIdMismatch, null); + + if (thisEvent.ValidFromBeforeStartDate.HasValue && DateTime.Now < thisEvent.Start - thisEvent.ValidFromBeforeStartDate) + return (ReserveOrderItemsResult.OpportunityOfferPairNotBookable, null); + + // Remove existing leases + // Note a real implementation would likely maintain existing leases instead of removing and recreating them + db.Delete(x => x.ClientId == clientId && x.OrderId == uuid && x.EventId == eventId); + RecalculateEventSpaces(db, thisEvent); + + // Only lease if all spaces requested are available + if (thisEvent.RemainingSpaces - thisEvent.LeasedSpaces < numberOfSpaces) + return (ReserveOrderItemsResult.NotEnoughCapacity, null); + + var bookedOrderItemInfos = new List(); + for (var i = 0; i < numberOfSpaces; i++) + { + var orderItem = new OrderItemsTable + { + ClientId = clientId, + Deleted = false, + OrderId = uuid, + Status = proposal ? BookingStatus.Proposed : BookingStatus.Confirmed, + EventId = eventId, + OpportunityJsonLdId = opportunityJsonLdId, + OfferJsonLdId = offerJsonLdId, + // Include the price locked into the OrderItem as the opportunity price may change + Price = thisEvent.Price.Value, + PinCode = thisEvent.AttendanceMode != AttendanceMode.Online ? Faker.Random.String(length: 6, minChar: '0', maxChar: '9') : null, + ImageUrl = thisEvent.AttendanceMode != AttendanceMode.Online ? Faker.Image.PlaceholderUrl(width: 25, height: 25) : null, + BarCodeText = thisEvent.AttendanceMode != AttendanceMode.Online ? Faker.Random.String(length: 10, minChar: '0', maxChar: '9') : null, + MeetingUrl = thisEvent.AttendanceMode != AttendanceMode.Offline ? new Uri(Faker.Internet.Url()) : null, + MeetingId = thisEvent.AttendanceMode != AttendanceMode.Offline ? Faker.Random.String(length: 10, minChar: '0', maxChar: '9') : null, + MeetingPassword = thisEvent.AttendanceMode != AttendanceMode.Offline ? Faker.Random.String(length: 10, minChar: '0', maxChar: '9') : null + }; + db.Save(orderItem); + bookedOrderItemInfos.Add(new BookedOrderItemInfo + { + OrderItemId = orderItem.Id, + PinCode = orderItem.PinCode, + ImageUrl = orderItem.ImageUrl, + BarCodeText = orderItem.BarCodeText, + MeetingId = orderItem.MeetingId, + MeetingPassword = orderItem.MeetingPassword, + AttendanceMode = thisEvent.AttendanceMode, + }); + } + + RecalculateEventSpaces(db, thisEvent); + return (ReserveOrderItemsResult.Success, bookedOrderItemInfos); + } + public bool CancelOrderItems(string clientId, long? sellerId, string uuid, List orderItemIds, bool customerCancelled, bool includeCancellationMessage = false) { using (var db = Mem.Database.Open()) @@ -1003,7 +1234,7 @@ public bool CancelOrderItems(string clientId, long? sellerId, string uuid, List< var query = db.From() .LeftJoin() .LeftJoin() - .LeftJoin() + .LeftJoin() .Where(whereClause); var orderItems = db .SelectMulti(query) @@ -1012,7 +1243,7 @@ public bool CancelOrderItems(string clientId, long? sellerId, string uuid, List< var updatedOrderItems = new List(); - foreach (var (orderItem, slot, occurrence, @class) in orderItems) + foreach (var (orderItem, slot, occurrence, @event) in orderItems) { var now = DateTime.Now; @@ -1020,30 +1251,58 @@ public bool CancelOrderItems(string clientId, long? sellerId, string uuid, List< // If it's the seller cancelling, this restriction does not apply. if (customerCancelled) { - if (slot.Id != 0 && slot.LatestCancellationBeforeStartDate != null && - slot.Start - slot.LatestCancellationBeforeStartDate < now) + if (slot.Id != 0) { - transaction.Rollback(); - throw new InvalidOperationException("Customer cancellation not permitted as outside the refund window for the slot"); - } + if (slot.LatestCancellationBeforeStartDate != null && + slot.Start - slot.LatestCancellationBeforeStartDate < now) + { + transaction.Rollback(); + throw new InvalidOperationException("Customer cancellation not permitted as outside the refund window for the slot"); - if (occurrence.Id != 0 && - @class?.LatestCancellationBeforeStartDate != null && - occurrence.Start - @class.LatestCancellationBeforeStartDate < now) - { - transaction.Rollback(); - throw new InvalidOperationException("Customer cancellation not permitted as outside the refund window for the session"); + } + + if (slot.AllowCustomerCancellationFullRefund == false) + { + transaction.Rollback(); + throw new InvalidOperationException("Customer cancellation not permitted on this slot"); + } } - if (slot.Id != 0 && slot.AllowCustomerCancellationFullRefund == false) + + if (occurrence.Id != 0) { - transaction.Rollback(); - throw new InvalidOperationException("Customer cancellation not permitted on this slot"); + var classQuery = db.From() + .LeftJoin() + .Where(x => x.Id == occurrence.Id); + var @class = db.Single(classQuery); + + if (@class?.LatestCancellationBeforeStartDate != null && + occurrence.Start - @class.LatestCancellationBeforeStartDate < now) + { + transaction.Rollback(); + throw new InvalidOperationException("Customer cancellation not permitted as outside the refund window for the session"); + } + if (@class.AllowCustomerCancellationFullRefund == false) + { + transaction.Rollback(); + throw new InvalidOperationException("Customer cancellation not permitted on this session"); + } } - if (occurrence.Id != 0 && - @class.AllowCustomerCancellationFullRefund == false) + + if (@event.Id != 0) { - transaction.Rollback(); - throw new InvalidOperationException("Customer cancellation not permitted on this session"); + if (@event.IsEvent && @event.LatestCancellationBeforeStartDate != null && + @event.Start - @event.LatestCancellationBeforeStartDate < now) + { + transaction.Rollback(); + throw new InvalidOperationException("Customer cancellation not permitted as outside the refund window for the event"); + } + + + if (@event.IsEvent && @event.AllowCustomerCancellationFullRefund == false) + { + transaction.Rollback(); + throw new InvalidOperationException("Customer cancellation not permitted on this event"); + } } if (orderItem.Status == BookingStatus.CustomerCancelled) @@ -1087,6 +1346,8 @@ public bool CancelOrderItems(string clientId, long? sellerId, string uuid, List< // Update the number of spaces available as a result of cancellation RecalculateSpaces(db, updatedOrderItems.Where(x => x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value).Distinct()); RecalculateSlotUses(db, updatedOrderItems.Where(x => x.SlotId.HasValue).Select(x => x.SlotId.Value).Distinct()); + RecalculateEventSpaces(db, updatedOrderItems.Where(x => x.EventId.HasValue).Select(x => x.EventId.Value).Distinct()); + } transaction.Commit(); @@ -1151,6 +1412,16 @@ public bool ReplaceOrderOpportunity(string uuid) orderItem.OccurrenceId = occurrence.Id; } + else if (orderItem.EventId.HasValue) + { + var oldEvent = db.Single(c => c.Id == orderItem.EventId.Value); + var newEvent = db.Single(c => c.Id != orderItem.EventId.Value && c.Price <= orderItem.Price && c.IsEvent); + + // Hack to replace JSON LD Ids + orderItem.OpportunityJsonLdId = new Uri(orderItem.OpportunityJsonLdId.ToString().Replace($"events/{oldEvent.Id}", $"events/{newEvent.Id}")); + orderItem.OfferJsonLdId = new Uri(orderItem.OfferJsonLdId.ToString().Replace($"events/{oldEvent.Id}", $"events/{newEvent.Id}")); + + } else { return false; @@ -1167,6 +1438,7 @@ public bool ReplaceOrderOpportunity(string uuid) // Update the number of spaces available as a result of cancellation RecalculateSpaces(db, orderItems.Where(x => x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value).Distinct()); RecalculateSlotUses(db, orderItems.Where(x => x.SlotId.HasValue).Select(x => x.SlotId.Value).Distinct()); + RecalculateEventSpaces(db, orderItems.Where(x => x.EventId.HasValue).Select(x => x.EventId.Value).Distinct()); return true; } } @@ -1262,6 +1534,7 @@ public FakeDatabaseBookOrderProposalResult BookOrderProposal(string clientId, lo // Update the number of spaces available as a result of cancellation RecalculateSpaces(db, updatedOrderItems.Where(x => x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value).Distinct()); RecalculateSlotUses(db, updatedOrderItems.Where(x => x.SlotId.HasValue).Select(x => x.SlotId.Value).Distinct()); + RecalculateEventSpaces(db, updatedOrderItems.Where(x => x.EventId.HasValue).Select(x => x.EventId.Value).Distinct()); } return FakeDatabaseBookOrderProposalResult.OrderSuccessfullyBooked; } @@ -1296,6 +1569,18 @@ public long GetNumberOfOtherLeasesForSlot(string uuid, long? slotId) } } + public long GetNumberOfOtherLeasesForEvent(string uuid, long? eventId) + { + using (var db = Mem.Database.Open()) + { + return db.Count(x => x.OrderTable.OrderMode != OrderMode.Booking && + x.OrderTable.ProposalStatus != ProposalStatus.CustomerRejected && + x.OrderTable.ProposalStatus != ProposalStatus.SellerRejected && + x.EventId == eventId && + x.OrderId != uuid); + } + } + public bool RejectOrderProposal(string clientId, long? sellerId, string uuid, bool customerRejected) { using (var db = Mem.Database.Open()) @@ -1319,6 +1604,7 @@ public bool RejectOrderProposal(string clientId, long? sellerId, string uuid, bo List updatedOrderItems = db.Select(x => (clientId == null || x.ClientId == clientId) && x.OrderId == order.OrderId).ToList(); RecalculateSpaces(db, updatedOrderItems.Where(x => x.OccurrenceId.HasValue).Select(x => x.OccurrenceId.Value).Distinct()); RecalculateSlotUses(db, updatedOrderItems.Where(x => x.SlotId.HasValue).Select(x => x.SlotId.Value).Distinct()); + RecalculateEventSpaces(db, updatedOrderItems.Where(x => x.EventId.HasValue).Select(x => x.EventId.Value).Distinct()); } return true; } @@ -1384,6 +1670,33 @@ public static void RecalculateSpaces(IDbConnection db, IEnumerable occurre } } + public static void RecalculateEventSpaces(IDbConnection db, IEnumerable eventIds) + { + foreach (var eventId in eventIds) + { + var thisEvent = db.Single(x => x.Id == eventId && !x.Deleted); + RecalculateEventSpaces(db, thisEvent); + } + } + + public static void RecalculateEventSpaces(IDbConnection db, ClassTable @event) + { + if (@event == null) + return; + + // Update number of leased spaces remaining for the opportunity + var leasedSpaces = db.LoadSelect(x => x.OrderTable.OrderMode != OrderMode.Booking && x.OrderTable.ProposalStatus != ProposalStatus.CustomerRejected && x.OrderTable.ProposalStatus != ProposalStatus.SellerRejected && x.EventId == @event.Id).Count(); + @event.LeasedSpaces = leasedSpaces; + + // Update number of actual spaces remaining for the opportunity + var totalSpacesTaken = db.LoadSelect(x => x.OrderTable.OrderMode == OrderMode.Booking && x.EventId == @event.Id && (x.Status == BookingStatus.Confirmed || x.Status == BookingStatus.Attended)).Count(); + @event.RemainingSpaces = @event.TotalSpaces - totalSpacesTaken; + + // Push the change into the future to avoid it getting lost in the feed (see race condition transaction challenges https://developer.openactive.io/publishing-data/data-feeds/implementing-rpde-feeds#preventing-the-race-condition) // TODO: Document this! + @event.Modified = DateTimeOffset.Now.UtcTicks; + db.Update(@event); + } + public static FakeDatabase GetPrepopulatedFakeDatabase() { var database = new FakeDatabase(); @@ -1395,12 +1708,57 @@ public static FakeDatabase GetPrepopulatedFakeDatabase() CreateSellerUsers(db); CreateFakeClasses(db); CreateFakeFacilitiesAndSlots(db); + CreateFakeEvents(db); CreateBookingPartners(db); transaction.Commit(); } return database; } + private static void CreateFakeEvents(IDbConnection db) + { + var opportunitySeeds = GenerateOpportunitySeedDistribution(OpportunityCount); + + var events = opportunitySeeds + .Select(seed => Enumerable.Range(0, 10) + .Select((_) => + { + var requiresAdditionalDetails = Faker.Random.Bool(ProportionWithRequiresAdditionalDetails); + var price = decimal.Parse(Faker.Random.Bool() ? "0.00" : Faker.Commerce.Price(0, 20)); + var startTime = seed.RandomStartDate(); + var totalSpaces = Faker.Random.Bool() ? Faker.Random.Int(0, 50) : Faker.Random.Int(0, 3); + + return new ClassTable + { + Id = seed.Id, + Deleted = false, + Title = $"{Faker.Commerce.ProductMaterial()} {Faker.PickRandomParam("Yoga", "Zumba", "Walking", "Cycling", "Running", "Jumping")}", + Price = price, + Prepayment = price == 0 + ? Faker.Random.Bool() ? RequiredStatusType.Unavailable : (RequiredStatusType?)null + : Faker.Random.Bool() ? Faker.Random.Enum() : (RequiredStatusType?)null, + RequiresAttendeeValidation = Faker.Random.Bool(ProportionWithRequiresAttendeeValidation), + RequiresAdditionalDetails = requiresAdditionalDetails, + RequiredAdditionalDetails = requiresAdditionalDetails ? PickRandomAdditionalDetails() : null, + RequiresApproval = seed.RequiresApproval, + AllowsProposalAmendment = seed.RequiresApproval ? Faker.Random.Bool() : false, + LatestCancellationBeforeStartDate = RandomLatestCancellationBeforeStartDate(), + SellerId = Faker.Random.Bool(0.8f) ? Faker.Random.Long(1, 2) : Faker.Random.Long(3, 5), // distribution: 80% 1-2, 20% 3-5 + ValidFromBeforeStartDate = seed.RandomValidFromBeforeStartDate(), + AttendanceMode = Faker.PickRandom(), + AllowCustomerCancellationFullRefund = Faker.Random.Bool(), + Start = startTime, + End = startTime + TimeSpan.FromMinutes(Faker.Random.Int(30, 360)), + TotalSpaces = totalSpaces, + RemainingSpaces = totalSpaces, + IsEvent = true + }; + }) + ).SelectMany(os => os); + + db.InsertAll(events); + } + private static void CreateFakeFacilitiesAndSlots(IDbConnection db) { var opportunitySeeds = GenerateOpportunitySeedDistribution(OpportunityCount); @@ -1466,7 +1824,7 @@ public static void CreateFakeClasses(IDbConnection db) seed.Id, Price = decimal.Parse(Faker.Random.Bool() ? "0.00" : Faker.Commerce.Price(0, 20)), ValidFromBeforeStartDate = seed.RandomValidFromBeforeStartDate(), - seed.RequiresApproval + seed.RequiresApproval, }) .Select((@class) => { @@ -1489,7 +1847,11 @@ public static void CreateFakeClasses(IDbConnection db) SellerId = Faker.Random.Bool(0.8f) ? Faker.Random.Long(1, 2) : Faker.Random.Long(3, 5), // distribution: 80% 1-2, 20% 3-5 ValidFromBeforeStartDate = @class.ValidFromBeforeStartDate, AttendanceMode = Faker.PickRandom(), - AllowCustomerCancellationFullRefund = Faker.Random.Bool() + AllowCustomerCancellationFullRefund = Faker.Random.Bool(), + IsEvent = false, + PartialScheduleDay = Faker.PickRandom(), + PartialScheduleTime = DateTime.Parse(Faker.Random.ArrayElement(new string[] { "10:00 AM", "12:00PM", "18:00 PM", "20:00 PM" })), + PartialScheduleDuration = TimeSpan.FromMinutes(Faker.Random.Int(1, 120)), }; }) .ToList(); @@ -1791,6 +2153,69 @@ public void RemoveGrant(string subjectId, string clientId, string type) } } + public int AddEvent( + string testDatasetIdentifier, + long? sellerId, + string title, + decimal? price, + long totalSpaces, + bool requiresApproval = false, + bool? validFromStartDate = null, + bool? latestCancellationBeforeStartDate = null, + bool allowCustomerCancellationFullRefund = true, + RequiredStatusType? prepayment = null, + bool requiresAttendeeValidation = false, + bool requiresAdditionalDetails = false, + decimal locationLat = 0.1m, + decimal locationLng = 0.1m, + bool isOnlineOrMixedAttendanceMode = false, + bool allowProposalAmendment = false) + + { + var startTime = DateTime.Now.AddDays(1); + var endTime = DateTime.Now.AddDays(1).AddHours(1); + + using (var db = Mem.Database.Open()) + using (var transaction = db.OpenTransaction(IsolationLevel.Serializable)) + { + var @event = new ClassTable + { + TestDatasetIdentifier = testDatasetIdentifier, + Deleted = false, + Title = title, + Price = price, + Prepayment = prepayment, + SellerId = sellerId ?? 1, + RequiresApproval = requiresApproval, + ValidFromBeforeStartDate = validFromStartDate.HasValue + ? TimeSpan.FromHours(validFromStartDate.Value ? 48 : 4) + : (TimeSpan?)null, + LatestCancellationBeforeStartDate = latestCancellationBeforeStartDate.HasValue + ? TimeSpan.FromHours(latestCancellationBeforeStartDate.Value ? 4 : 48) + : (TimeSpan?)null, + RequiresAttendeeValidation = requiresAttendeeValidation, + RequiresAdditionalDetails = requiresAdditionalDetails, + RequiredAdditionalDetails = requiresAdditionalDetails ? PickRandomAdditionalDetails() : null, + AllowsProposalAmendment = allowProposalAmendment, + LocationLat = locationLat, + LocationLng = locationLng, + AttendanceMode = isOnlineOrMixedAttendanceMode ? Faker.PickRandom(new[] { AttendanceMode.Mixed, AttendanceMode.Online }) : AttendanceMode.Offline, + AllowCustomerCancellationFullRefund = allowCustomerCancellationFullRefund, + Start = startTime, + End = endTime, + TotalSpaces = totalSpaces, + RemainingSpaces = totalSpaces, + Modified = DateTimeOffset.Now.UtcTicks, + IsEvent = true + }; + db.Save(@event); + + transaction.Commit(); + + return (int)@event.Id; + } + } + public (int, int) AddClass( string testDatasetIdentifier, long? sellerId, @@ -1816,6 +2241,7 @@ public void RemoveGrant(string subjectId, string clientId, string type) using (var db = Mem.Database.Open()) using (var transaction = db.OpenTransaction(IsolationLevel.Serializable)) { + var hasPartialSchedules = Faker.Random.Bool(0.3f); var @class = new ClassTable { TestDatasetIdentifier = testDatasetIdentifier, @@ -1839,7 +2265,11 @@ public void RemoveGrant(string subjectId, string clientId, string type) LocationLng = locationLng, AttendanceMode = isOnlineOrMixedAttendanceMode ? Faker.PickRandom(new[] { AttendanceMode.Mixed, AttendanceMode.Online }) : AttendanceMode.Offline, AllowCustomerCancellationFullRefund = allowCustomerCancellationFullRefund, - Modified = DateTimeOffset.Now.UtcTicks + Modified = DateTimeOffset.Now.UtcTicks, + IsEvent = false, + PartialScheduleDay = Faker.PickRandom(), + PartialScheduleTime = DateTime.Parse(Faker.Random.ArrayElement(new string[] { "10:00 AM", "12:00PM", "18:00 PM", "20:00 PM" })), + PartialScheduleDuration = TimeSpan.FromMinutes(Faker.Random.Int(1, 120)), }; db.Save(@class); @@ -1953,6 +2383,16 @@ public void DeleteTestFacilitiesFromDataset(string testDatasetIdentifier) where: x => x.TestDatasetIdentifier == testDatasetIdentifier && !x.Deleted); } } + + public void DeleteTestEventsFromDataset(string testDatasetIdentifier) + { + using (var db = Mem.Database.Open()) + { + db.UpdateOnly(() => new ClassTable { Modified = DateTimeOffset.Now.UtcTicks, Deleted = true }, + where: x => x.TestDatasetIdentifier == testDatasetIdentifier && !x.Deleted && x.IsEvent); + } + } + private static readonly (Bounds, Bounds?, bool)[] BucketDefinitions = { // Approval not required