public override ValueTask UpdateLease( OrderQuote responseOrderQuote, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { // Does nothing at the moment return(new ValueTask()); }
private bool ReconciliationMismatch(StoreBookingFlowContext flowContext) { // MissingPaymentDetailsError is handled by OpenActive.Server.NET, so ignoring empty payment details here allows the exception to be thrown by the booking engine. if (flowContext.Payment == null) { return(false); } return(flowContext.Payment.AccountId != _appSettings.Payment.AccountId || flowContext.Payment.PaymentProviderId != _appSettings.Payment.PaymentProviderId); }
public override void CreateOrder(Order responseOrder, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { var result = databaseTransaction.Database.AddOrder( flowContext.OrderId.ClientId, flowContext.OrderId.uuid, flowContext.BrokerRole == BrokerType.AgentBroker ? BrokerRole.AgentBroker : flowContext.BrokerRole == BrokerType.ResellerBroker ? BrokerRole.ResellerBroker : BrokerRole.NoBroker, flowContext.Broker.Name, flowContext.SellerId.SellerIdLong ?? null, // Small hack to allow use of FakeDatabase when in Single Seller mode flowContext.Customer.Email, flowContext.Payment?.Identifier, responseOrder.TotalPaymentDue.Price.Value, databaseTransaction.Transaction); if (!result) { throw new OpenBookingException(new OrderAlreadyExistsError()); } }
public override async ValueTask <Lease> CreateLease( OrderQuote responseOrderQuote, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { if (_appSettings.FeatureFlags.PaymentReconciliationDetailValidation && ReconciliationMismatch(flowContext)) { throw new OpenBookingException(new InvalidPaymentDetailsError(), "Payment reconciliation details do not match"); } // Note if no lease support, simply return null always here instead if (flowContext.Stage != FlowStage.C1 && flowContext.Stage != FlowStage.C2) { return(null); } // TODO: Make the lease duration configurable var leaseExpires = DateTimeOffset.UtcNow + new TimeSpan(0, 5, 0); var brokerRole = BrokerTypeToBrokerRole(flowContext.BrokerRole ?? BrokerType.NoBroker); var result = await FakeDatabase.AddLease( flowContext.OrderId.ClientId, flowContext.OrderId.uuid, brokerRole, flowContext.Broker.Name, flowContext.Broker.Url, flowContext.Broker.Telephone, flowContext.SellerId.SellerIdLong ?? null, // Small hack to allow use of FakeDatabase when in Single Seller mode flowContext.Customer?.Email, leaseExpires, databaseTransaction.FakeDatabaseTransaction); if (!result) { throw new OpenBookingException(new OrderAlreadyExistsError()); } return(new Lease { LeaseExpires = leaseExpires }); }
public override async ValueTask CreateOrder(Order responseOrder, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { if (_appSettings.FeatureFlags.PaymentReconciliationDetailValidation && responseOrder.TotalPaymentDue.Price > 0 && ReconciliationMismatch(flowContext)) { throw new OpenBookingException(new InvalidPaymentDetailsError(), "Payment reconciliation details do not match"); } var customerType = flowContext.Customer == null ? CustomerType.None : (flowContext.Customer.IsOrganization ? CustomerType.Organization : CustomerType.Person); var result = await FakeDatabase.AddOrder( flowContext.OrderId.ClientId, flowContext.OrderId.uuid, flowContext.BrokerRole == BrokerType.AgentBroker?BrokerRole.AgentBroker : flowContext.BrokerRole == BrokerType.ResellerBroker?BrokerRole.ResellerBroker : BrokerRole.NoBroker, flowContext.Broker.Name, flowContext.Broker.Url, flowContext.Broker.Telephone, flowContext.SellerId.SellerIdLong ?? null, // Small hack to allow use of FakeDatabase when in Single Seller mode customerType == CustomerType.None?null : flowContext.Customer.Email, customerType, customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? flowContext.Customer.Name : null), customerType == CustomerType.None?null : flowContext.Customer.Identifier.HasValue?flowContext.Customer.Identifier.Value.ToString() : null, customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? null : flowContext.Customer.GivenName), customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? null : flowContext.Customer.FamilyName), customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? null : flowContext.Customer.Telephone), flowContext.Payment?.Identifier, flowContext.Payment?.Name, flowContext.Payment?.PaymentProviderId, flowContext.Payment?.AccountId, responseOrder.TotalPaymentDue.Price.Value, databaseTransaction.FakeDatabaseTransaction, null, null); if (!result) { throw new OpenBookingException(new OrderAlreadyExistsError()); } }
public override Lease CreateLease(OrderQuote responseOrderQuote, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { // Note if no lease support, simply return null always here instead // In this example leasing is only supported at C2 if (flowContext.Stage == FlowStage.C2) { // TODO: Make the lease duration configurable var leaseExpires = DateTimeOffset.UtcNow + new TimeSpan(0, 5, 0); var result = databaseTransaction.Database.AddLease( flowContext.OrderId.ClientId, flowContext.OrderId.uuid, flowContext.BrokerRole == BrokerType.AgentBroker ? BrokerRole.AgentBroker : flowContext.BrokerRole == BrokerType.ResellerBroker ? BrokerRole.ResellerBroker : BrokerRole.NoBroker, flowContext.Broker.Name, flowContext.SellerId.SellerIdLong ?? null, // Small hack to allow use of FakeDatabase when in Single Seller mode flowContext.Customer.Email, leaseExpires, databaseTransaction?.Transaction ); if (!result) { throw new OpenBookingException(new OrderAlreadyExistsError()); } return(new Lease { LeaseExpires = leaseExpires }); } else { return(null); } }
public override async ValueTask <(Guid, OrderProposalStatus)> CreateOrderProposal(OrderProposal responseOrderProposal, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { if (!responseOrderProposal.TotalPaymentDue.Price.HasValue) { throw new OpenBookingException(new OpenBookingError(), "Price must be set on TotalPaymentDue"); } var version = Guid.NewGuid(); var customerType = flowContext.Customer == null ? CustomerType.None : (flowContext.Customer.GetType() == typeof(Organization) ? CustomerType.Organization : CustomerType.Person); var result = await FakeDatabase.AddOrder( flowContext.OrderId.ClientId, flowContext.OrderId.uuid, flowContext.BrokerRole == BrokerType.AgentBroker?BrokerRole.AgentBroker : flowContext.BrokerRole == BrokerType.ResellerBroker?BrokerRole.ResellerBroker : BrokerRole.NoBroker, flowContext.Broker.Name, flowContext.Broker.Url, flowContext.Broker.Telephone, flowContext.SellerId.SellerIdLong ?? null, // Small hack to allow use of FakeDatabase when in Single Seller mode customerType == CustomerType.None?null : flowContext.Customer.Email, customerType, customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? flowContext.Customer.Name : null), customerType == CustomerType.None?null : flowContext.Customer.Identifier.HasValue?flowContext.Customer.Identifier.Value.ToString() : null, customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? null : flowContext.Customer.GivenName), customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? null : flowContext.Customer.FamilyName), customerType == CustomerType.None?null : (customerType == CustomerType.Organization ? null : flowContext.Customer.Telephone), flowContext.Payment?.Identifier, flowContext.Payment?.Name, flowContext.Payment?.PaymentProviderId, flowContext.Payment?.AccountId, responseOrderProposal.TotalPaymentDue.Price.Value, databaseTransaction.FakeDatabaseTransaction, version, ProposalStatus.AwaitingSellerConfirmation); if (!result) { throw new OpenBookingException(new OrderAlreadyExistsError()); } return(version, OrderProposalStatus.AwaitingSellerConfirmation); }
public override ValueTask <OrderStateContext> Initialise(StoreBookingFlowContext flowContext) { // Runs before the flow starts, for both leasing and booking // Useful for transferring state between stages of the flow return(new ValueTask <OrderStateContext>(new OrderStateContext())); }
// TODO check logic here, it's just been copied from BookOrderItems. Possibly could remove duplication here. protected override async ValueTask ProposeOrderItems(List <OrderItemContext <FacilityOpportunity> > 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.FacilityUseSlot || !ctxGroup.Key.SlotId.HasValue) { throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity ID and type are as not expected for the FacilityStore, during proposal"); } // Attempt to book for those with the same IDs, which is atomic var(result, bookedOrderItemInfos) = await FakeDatabase.BookOrderItemsForFacilitySlot( 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.SlotId.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"); } } }
//TODO: This should reuse code of LeaseOrderItem protected override async ValueTask BookOrderItems(List <OrderItemContext <SessionOpportunity> > 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 // TODO: ENSURE THAT THIS IS CALLED EVERY TIME BY THE STOREBOOKINGENGINE, EVEN WITH ZERO ITEMS // This will ensure that items can be removed from the Order before the booking is confirmed if all items of that type have been removed from the lease // Step 1: Call lease to ensure items are already leased // Step 2: Set OrderItems to booked // Step 3: Write attendee and orderItemIntakeFormResponse to the OrderItem records, for inclusion in P later 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.ScheduledSession || !ctxGroup.Key.ScheduledSessionId.HasValue) { throw new OpenBookingException(new UnableToProcessOrderItemError(), "Opportunity ID and type are as not expected for the SessionStore, during booking"); } // Attempt to book for those with the same IDs, which is atomic var(result, bookedOrderItemInfos) = await FakeDatabase.BookOrderItemsForClassOccurrence( 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.ScheduledSessionId.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"); } } }
// 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 override async Task GetOrderItems(List <OrderItemContext <FacilityOpportunity> > 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 = await Task.WhenAll(orderItemContexts.Select(async orderItemContext => { var getOccurrenceInfoResult = await FakeBookingSystem.Database.GetSlotAndBookedOrderItemInfoBySlotId(flowContext.OrderId.uuid, orderItemContext.RequestBookableOpportunityOfferId.SlotId); var(hasFoundOccurrence, facility, slot, bookedOrderItemInfo) = getOccurrenceInfoResult; if (hasFoundOccurrence == false) { return(null); } var remainingUsesIncludingOtherLeases = await FakeBookingSystem.Database.GetNumberOfOtherLeasesForSlot(flowContext.OrderId.uuid, orderItemContext.RequestBookableOpportunityOfferId.SlotId); return(new { OrderItem = new OrderItem { // TODO: The static example below should come from the database (which doesn't currently support tax) UnitTaxSpecification = GetUnitTaxSpecification(flowContext, slot), 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 = slot.Price, PriceCurrency = "GBP", LatestCancellationBeforeStartDate = slot.LatestCancellationBeforeStartDate, OpenBookingPrepayment = slot.Prepayment.Convert(), ValidFromBeforeStartDate = slot.ValidFromBeforeStartDate, AllowCustomerCancellationFullRefund = slot.AllowCustomerCancellationFullRefund, }, OrderedItem = new Slot { // Note this should always be driven from the database, with new FacilityOpportunity's instantiated Id = RenderOpportunityId(new FacilityOpportunity { OpportunityType = OpportunityType.FacilityUseSlot, FacilityUseId = slot.FacilityUseId, SlotId = slot.Id }), FacilityUse = new FacilityUse { Id = RenderOpportunityId(new FacilityOpportunity { OpportunityType = OpportunityType.FacilityUse, FacilityUseId = slot.FacilityUseId }), Name = facility.Name, Url = new Uri("https://example.com/events/" + slot.FacilityUseId), Location = new Place { Name = "Fake fitness studio", Geo = new GeoCoordinates { Latitude = facility.LocationLat, Longitude = facility.LocationLng, } }, Activity = new List <Concept> { 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)slot.Start, EndDate = (DateTimeOffset)slot.End, MaximumUses = slot.MaximumUses, // Exclude current Order from the returned lease count RemainingUses = slot.RemainingUses - remainingUsesIncludingOtherLeases }, Attendee = orderItemContext.RequestOrderItem.Attendee, AttendeeDetailsRequired = slot.RequiresAttendeeValidation ? new List <PropertyEnumeration> { PropertyEnumeration.GivenName, PropertyEnumeration.FamilyName, PropertyEnumeration.Email, PropertyEnumeration.Telephone, } : null, OrderItemIntakeForm = slot.RequiresAdditionalDetails ? PropertyValueSpecificationHelper.HydrateAdditionalDetailsIntoPropertyValueSpecifications(slot.RequiredAdditionalDetails) : null, OrderItemIntakeFormResponse = orderItemContext.RequestOrderItem.OrderItemIntakeFormResponse, }, SellerId = _appSettings.FeatureFlags.SingleSeller ? new SellerIdComponents() : new SellerIdComponents { SellerIdLong = facility.SellerId }, slot.RequiresApproval, BookedOrderItemInfo = bookedOrderItemInfo, slot.RemainingUses }); })); // 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.RemainingUses == 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); } } } }
public override void UpdateOrder(Order responseOrder, StoreBookingFlowContext flowContext, OrderStateContext stateContext, OrderTransaction databaseTransaction) { // Runs after the transaction is committed }
// 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 override void GetOrderItem(List <OrderItemContext <SessionOpportunity> > 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 = (from orderItemContext in orderItemContexts join occurances in FakeBookingSystem.Database.Occurrences on orderItemContext.RequestBookableOpportunityOfferId.ScheduledSessionId equals occurances.Id join classes in FakeBookingSystem.Database.Classes on occurances.ClassId equals classes.Id // and offers.id = opportunityOfferId.OfferId select occurances == null ? null : new OrderItem { AllowCustomerCancellationFullRefund = true, // TODO: The static example below should come from the database (which doesn't currently support tax) UnitTaxSpecification = flowContext.TaxPayeeRelationship == TaxPayeeRelationship.BusinessToConsumer ? new List <TaxChargeSpecification> { new TaxChargeSpecification { Name = "VAT at 20%", Price = classes.Price * (decimal?)0.2, PriceCurrency = "GBP", Rate = (decimal?)0.2 } } : null, AcceptedOffer = new Offer { // Note this should always use RenderOfferId with the supplied SessionOpportunity, to take into account inheritance and OfferType Id = this.RenderOfferId(orderItemContext.RequestBookableOpportunityOfferId), Price = classes.Price, PriceCurrency = "GBP" }, OrderedItem = new ScheduledSession { // Note this should always be driven from the database, with new SessionOpportunity's instantiated Id = this.RenderOpportunityId(new SessionOpportunity { OpportunityType = OpportunityType.ScheduledSession, SessionSeriesId = occurances.ClassId, ScheduledSessionId = occurances.Id }), SuperEvent = new SessionSeries { Id = this.RenderOpportunityId(new SessionOpportunity { OpportunityType = OpportunityType.SessionSeries, SessionSeriesId = occurances.ClassId }), Name = classes.Title, Url = new Uri("https://example.com/events/" + occurances.ClassId), Location = new Place { Name = "Fake fitness studio", Geo = new GeoCoordinates { Latitude = 51.6201M, Longitude = 0.302396M } }, Activity = new List <Concept> { 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)occurances.Start, EndDate = (DateTimeOffset)occurances.End, MaximumAttendeeCapacity = occurances.TotalSpaces, RemainingAttendeeCapacity = occurances.RemainingSpaces } }); // 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); if (item.OrderedItem.RemainingAttendeeCapacity == 0) { ctx.AddError(new OpportunityIsFullError()); } } } // Add errors to the response according to the attendee details specified as required in the ResponseOrderItem, // and those provided in the requestOrderItem orderItemContexts.ForEach(ctx => ctx.ValidateAttendeeDetails()); // Additional attendee detail validation logic goes here // ... }
//TODO: This should reuse code of LeaseOrderItem protected override void BookOrderItem(List <OrderItemContext <SessionOpportunity> > 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.ScheduledSession || !ctxGroup.Key.ScheduledSessionId.HasValue) { throw new OpenBookingException(new UnableToProcessOrderItemError()); } // Attempt to book for those with the same IDs, which is atomic List <long> orderItemIds = databaseTransaction.Database.BookOrderItemsForClassOccurrence(flowContext.OrderId.ClientId, flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, flowContext.OrderId.uuid, ctxGroup.Key.ScheduledSessionId.Value, this.RenderOpportunityJsonLdType(ctxGroup.Key), this.RenderOpportunityId(ctxGroup.Key).ToString(), this.RenderOfferId(ctxGroup.Key).ToString(), ctxGroup.Count()); if (orderItemIds != null) { // Set OrderItemId for each orderItemContext foreach (var(ctx, id) in ctxGroup.Zip(orderItemIds, (ctx, id) => (ctx, id))) { ctx.SetOrderItemId(flowContext, id); } } else { // Note: A real implementation would not through an error this vague throw new OpenBookingException(new OrderCreationFailedError(), "Booking failed for an unexpected reason"); } } }
protected override void LeaseOrderItem(Lease lease, List <OrderItemContext <SessionOpportunity> > 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.ScheduledSession || !ctxGroup.Key.ScheduledSessionId.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 bool result = databaseTransaction.Database.LeaseOrderItemsForClassOccurrence(flowContext.OrderId.ClientId, flowContext.SellerId.SellerIdLong ?? null /* Hack to allow this to work in Single Seller mode too */, flowContext.OrderId.uuid, ctxGroup.Key.ScheduledSessionId.Value, ctxGroup.Count()); if (!result) { foreach (var ctx in ctxGroup) { ctx.AddError(new OpportunityIntractableError(), "OrderItem could not be leased for unexpected reasons."); } } } } }
protected override async ValueTask LeaseOrderItems(Lease lease, List <OrderItemContext <FacilityOpportunity> > 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.Where(ctx => !ctx.HasErrors).GroupBy(x => x.RequestBookableOpportunityOfferId)) { // Check that the Opportunity ID and type are as expected for the store if (ctxGroup.Key.OpportunityType != OpportunityType.FacilityUseSlot || !ctxGroup.Key.SlotId.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) = await FakeDatabase.LeaseOrderItemsForFacilitySlot(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.SlotId.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; } } } }
public static void AugmentOrderWithTotals <TOrder>( TOrder order, StoreBookingFlowContext context, bool businessToConsumerTaxCalculation, bool businessToBusinessTaxCalculation, bool prepaymentAlwaysRequired) where TOrder : Order { if (order == null) { throw new ArgumentNullException(nameof(order)); } // Calculate total payment due decimal totalPaymentDuePrice = 0; string totalPaymentDueCurrency = null; var totalPaymentTaxMap = new Dictionary <string, TaxChargeSpecification>(); foreach (OrderItem orderedItem in order.OrderedItem) { // Only items with no errors associated are included in the total price if (!(orderedItem.Error?.Count > 0)) { // Keep track of total price totalPaymentDuePrice += orderedItem.AcceptedOffer.Object.Price ?? 0; // Set currency based on first item if (totalPaymentDueCurrency == null) { totalPaymentDueCurrency = orderedItem.AcceptedOffer.Object.PriceCurrency; } else if (totalPaymentDueCurrency != orderedItem.AcceptedOffer.Object.PriceCurrency) { throw new InternalOpenBookingException(new InternalLibraryConfigurationError(), "All currencies in an Order must match"); } // Add the taxes to the map if (orderedItem.UnitTaxSpecification != null) { foreach (TaxChargeSpecification taxChargeSpecification in orderedItem.UnitTaxSpecification) { if (totalPaymentTaxMap.TryGetValue(taxChargeSpecification.Name, out TaxChargeSpecification currentTaxValue)) { totalPaymentTaxMap[taxChargeSpecification.Name] = AddTaxes(currentTaxValue, taxChargeSpecification); } else { totalPaymentTaxMap[taxChargeSpecification.Name] = taxChargeSpecification; } } } } } switch (context.TaxPayeeRelationship) { case TaxPayeeRelationship.BusinessToBusiness when businessToBusinessTaxCalculation: case TaxPayeeRelationship.BusinessToConsumer when businessToConsumerTaxCalculation: order.TotalPaymentTax = totalPaymentTaxMap.Values.ToListOrNullIfEmpty(); break; case TaxPayeeRelationship.BusinessToBusiness: case TaxPayeeRelationship.BusinessToConsumer: if (order.OrderedItem.Any(o => o.UnitTaxSpecification != null)) { throw new OpenBookingException(new InternalLibraryConfigurationError()); } order.TotalPaymentTax = null; order.TaxCalculationExcluded = true; break; } // If we're in Net taxMode, tax must be added to get the total price if (order.Seller.Object.TaxMode == TaxMode.TaxNet) { totalPaymentDuePrice += order.TotalPaymentTax.Sum(x => x.Price ?? 0); } order.TotalPaymentDue = new PriceSpecification { Price = totalPaymentDuePrice, PriceCurrency = totalPaymentDueCurrency }; if (prepaymentAlwaysRequired) { if (order.OrderedItem?.Any(x => x?.AcceptedOffer.Object?.OpenBookingPrepayment != null) == true) { throw new InternalOpenBookingException(new InternalLibraryConfigurationError(), "OpenBookingPrepayment must not be assigned in AcceptedOffer if PrepaymentAlwaysRequired is true"); } } else { order.TotalPaymentDue.OpenBookingPrepayment = GetRequiredStatusType(order.OrderedItem); } }
protected override async ValueTask LeaseOrderItems( Lease lease, List <OrderItemContext <SessionOpportunity> > 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.Where(ctx => !ctx.HasErrors).GroupBy(x => x.RequestBookableOpportunityOfferId)) { // TODO: ENSURE THAT THIS IS CALLED EVERY TIME BY THE STOREBOOKINGENGINE, EVEN WITH ZERO ITEMS // This will ensure that items can be removed from the Order before the booking is confirmed if all items of that type have been removed from the lease // Step 1: Get existing lease from database // Step 2: Compare items in the existing lease to items in the request // Step 3: Add/remove lease items to match the request //Dictionary<long, int> existingLease = new Dictionary<long, int>(); // Check that the Opportunity ID and type are as expected for the store if (ctxGroup.Key.OpportunityType != OpportunityType.ScheduledSession || !ctxGroup.Key.ScheduledSessionId.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 { //var existingOpportunities = existingLease[ctxGroup.Key.ScheduledSessionId.Value]; // Attempt to lease for those with the same IDs, which is atomic var(result, capacityErrors, capacityLeaseErrors) = await FakeDatabase.LeaseOrderItemsForClassOccurrence( 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.ScheduledSessionId.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; } } } }