openactive / open-booking-api

Repository for the Open Booking API specification
Other
2 stars 3 forks source link

Session extension to indicate membership inclusive classes #45

Open markthomasUprise opened 6 years ago

markthomasUprise commented 6 years ago

Which organisation(s) are proposing this issue?

Usecase

To enable membership options to be offered inline booking journey.

Please describe why your use case is not covered by the existing specification

Classes are not returned with a flag indicating which Memberships offer the class inclusive of the membership. Details of these Membership offers should be available elsewhere in the API.

What are you proposing should be changed in the specification?

Example

"offer": [
    {
        "name": "Non-member price",
        "price": 4.5,
        "priceCurrency": "GBP"
    },
    {
        "name": "Member price",
        "price": 4.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "Membership": [
            {
                "MembershipTypeId": 0,
                ...
                "MembershipTypeId": n
            }
        ],
    },
    {
        "name": "Member price",
        "price": 3.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "Membership": [
            {
                "MembershipTypeId": 0,
                ...
                "MembershipTypeId": n
            }
        ],
    }
],

References

MemberShipFlow.pdf

nickevansuk commented 6 years ago

Really great idea! Potentially we could use membership type IDs as references here, so something like the below? This way a membership such as https://api.example.com/memberships/1 could have an offer, which could be purchased, providing an easy route to "upsell" memberships to users looking at Pay as You Go.

So within GET /sessions:

"offers": [
    {
        "name": "Non-member price",
        "price": 4.5,
        "priceCurrency": "GBP"
    },
    {
        "name": "Member price",
        "price": 4.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembership": [
           "https://api.example.com/memberships/1",
           "https://api.example.com/memberships/2"
        ],
    },
    {
        "name": "Member price",
        "price": 3.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembership": [
           "https://api.example.com/memberships/3",
           "https://api.example.com/memberships/4"
        ],
    }
],

And as a response to GET /memberships/1 (taking inspiration from this proposal):

  {
    "type": "Membership",
    "id": "https://api.example.com/memberships/1",
    "name": "Off-Peak Fitness",
    "description": "Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times",
    "offers": [
        {
            "price": 45,
            "priceCurrency": "GBP",
            "paymentFrequency": "http://openactive.io/ns#Monthly",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/4"
        }
    ]
  }

(N.B. we probably need a whole new proposal for memberships, which includes potentialActions to allow them to be bookable natively. We also need to think about how they can be exposed as open data so they can be referenced from the main session feed.)

nickevansuk commented 6 years ago

One route might be to embed memberships in the feed itself as part of the event:

"offers": [
    {
        "name": "Non-member price",
        "price": 4.5,
        "priceCurrency": "GBP"
    },
    {
        "name": "Member price",
        "price": 4.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembership": [
           "https://api.example.com/memberships/1",
           "https://api.example.com/memberships/2"
        ],
    },
    {
        "name": "Member price",
        "price": 3.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembership": [
           "https://api.example.com/memberships/3",
           "https://api.example.com/memberships/4"
        ],
    }
],
"memberships": [
  {
    "type": "Membership",
    "id": "https://api.example.com/memberships/1",
    "name": "Off-Peak Fitness",
    "description": "Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times",
    "offers": [
        {
            "price": 45,
            "priceCurrency": "GBP",
            "paymentFrequency": "http://openactive.io/ns#Monthly",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/4"
        }
    ]
  }
]
peter-dolkens commented 6 years ago

Spec will also need to cover those venues who do not wish to make their member rates public.

Some venues will not be happy revealing grandfathered, exclusive, etc rates. In those cases, I think we can just leave "price" off the entry, as such:

"offers": [
    {
        "name": "Non-member price",
        "price": 4.5,
        "priceCurrency": "GBP"
    },
    {
        "name": "Member price",
        "price": 4.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembership": [
           "https://api.example.com/memberships/1",
           "https://api.example.com/memberships/2"
        ],
    },
    {
        "name": "Private Member price",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembership": [
           "https://api.example.com/memberships/3",
           "https://api.example.com/memberships/4"
        ],
    }
],
"memberships": [
  {
    "type": "Membership",
    "id": "https://api.example.com/memberships/1",
    "name": "Off-Peak Fitness",
    "description": "Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times",
    "offers": [
        {
            "price": 45,
            "priceCurrency": "GBP",
            "paymentFrequency": "http://openactive.io/ns#Monthly",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/4"
        }
    ]
  }
]
ldodds commented 6 years ago

As a general point of process, I think there's actually two aspects to this proposal:

  1. how do we describe member pricing and membership options within Offers. This is an extension/refinement to the modelling opportunity data specification.

  2. how do we support bookings by members / existing account holders within the booking specification? This would include ensuring that clients consistently apply rules to recommend prices and potentially checking membership/account holder status of a user.

I want to flag that the second aspect is currently out of scope for v1.0 of the booking API. This is based on the requirements and use cases we have agreed with the community.

Suggest we continue to discuss the changes to the data model here, but noting that the initial changes may be to make this information available as open data before its supported in booking.

nickevansuk commented 5 years ago

Noting here some overlap with https://github.com/openactive/open-booking-api/issues/60, where memberships might be better sat inside organizer

Also noting that schema.org's ProgramMembership could be used or subclassed for this purpose.

nickevansuk commented 5 years ago

Also note an exact mapping between the memberships and eligibleMembership approach above and Google Reserve's PaymentOption:

nickevansuk commented 5 years ago

Further notes on this considering properties that should be included in a membership; we should consider including:

availabilityStarts and validFrom

See https://github.com/openactive/open-booking-api/issues/59

eligibleDuration

From the [GoodRelations definition of eligibleDuration] (http://www.heppnetz.de/ontologies/goodrelations/v1.html#eligibleDuration):

The minimal and maximal duration for which the given gr:Offering or gr:License is valid. This is mostly used for offers regarding accommodation, the rental of objects, or software licenses. The duration is specified by attaching an instance of gr:QuantitativeValue. The lower and upper boundaries are specified using the properties gr:hasMinValue and gr:hasMaxValue to that instance. If they are the same, use the gr:hasValue property. The unit of measurement is specified using the property gr:hasUnitOfMeasurement with a string holding a UN/CEFACT code suitable for durations, e.g. MON (months), DAY (days), HUR (hours), or MIN (minutes).

So this can represent Google Reserve valid_duration_sec: "Duration of the payment option validity (e.g. 30 day membership).".

Note that eligibleDuration should be restricted to QuantitativeValue with a value and unitCode, rather than allowing a range. So only represent 30 day membership, as a 30-60 day membership is not a usecase we're aware of and will likely cause confusion. The unitCode should be restricted to only temporal codes (e.g. MON (months), DAY (days)). We should also consider allowing for Duration to be used instead of QuantitativeValue as a value eligibleDuration to be consistent with durations elsewhere.

eligibleQuantity

This can represent Google Reserve session_count: "How many sessions this payment option can be used for. Valid only for multi-session / packs, where the value should be > 1."

Makesweat already has notion of passes (see https://github.com/openactive/activation/issues/86) but is not marking them up specifically.

Note that eligibleQuantity should be restricted to QuantitativeValue with a value and unitCode, rather than allowing a range. So only represent 10 session pass, as a 10-20 session pass is not a usecase we're aware of and will likely cause confusion. The unitCode should be restricted to only the string C62 i.e. unit (as explained here), to indicate number of sessions included in the pass.

nickevansuk commented 5 years ago

Also perhaps Membership should be named more generically to include "10 session pass" as well as just "monthly membership". So perhaps "AccessPass"?

nickevansuk commented 5 years ago

It would also be useful to investigate PaymentOptionType, ActivationType and UserPurchaseRestriction from Google Reserve to see if we can add an equivalence here, while looking at broader use cases for these too.

nickevansuk commented 5 years ago

Another question is whether eligibleMembership should be able to be specified at Event/FacilityUse level as well as just Offer level (as an event might be eligible in its entirety regardless of which offer is selected). Google Reserve allows this.

Similar requirement to offerTemplate in https://github.com/openactive/open-booking-api/issues/60

nickevansuk commented 5 years ago

To add further thoughts to this, the types of membership options seem to break out into two main categories:

Also it's become apparent that although some publishers want to specify membership options in detail, and provide the option to purchase these, other publishers might want to simply specify a "membership required" flag for an Event or Offer with a link to where to find out more information.

Some properties that might be interesting to think about (see UserPaymentOption of Google Reserve):

nickevansuk commented 3 years ago

Another two type of "membership" / booking constraints to consider:

Both of the above constraints could be satisfied by an additional property on the "Offer".

nickevansuk commented 2 years ago

Further considerations as we move towards a concrete proposal:

This also mirrors what is described in Google Reserve: https://github.com/openactive/open-booking-api/issues/45#issuecomment-419706798

For large leisure operators, there a large number of possible memberships available across their Places, within the same Seller. For smaller organisations, it is likely that a Seller will have only a limited number of memberships (that may still be limited to Places.

A membership could be described in some detail, with a number of pricing options, and therefore the data object could be of a reasonable size.

Describing Memberships

Option 1

Embed memberships data in existing opportunity feeds

Advantages

Disadvantages

Option 2

A Sellers feed could be created that includes embedded membership data relating to each Seller. This also removes the need for Seller data to be included in every opportunity.

There has been discussion during a larger implementation on the advantages of encouraging the use of a Seller feed, for the same reason that a Places feed is beneficial.

Advantages

Disadvantages

Option 3

A separate memberships feed could be created, which references the Sellers

Advantages

Disadvantages

Option 4

Memberships could be embedded in Places, and therefore included in a Place feed.

For reference, for this option, the proposal below would be adjusted with the below in place of the MembershipProduct feed example.

Click here to expand ```json { "@type": "Place", ... "eligibleMembershipProduct": [ { "@type": "MembershipProduct", "@id": "https://api.example.com/membership-products/1", "name": "Off-Peak Fitness", "description": "Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times", "membershipProductType": "https://openactive.io/MembershipSubscription", "offers": [ { "@type": "Offer", "price": 45, "priceCurrency": "GBP", "paymentFrequency": "http://openactive.io/ns#Monthly", "url": "https://bookingsystem.example.com/membership/purchase/deep/link/4" } ] }, { "@type": "MembershipProduct", "@id": "https://api.example.com/membership-products/2", "name": "Yoga Off-Peak 5 Session Pass", "description": "5 yoga sessions at select facilities at off-peak times", "membershipProductType": "https://openactive.io/MembershipPass", "offers": [ { "@type": "Offer", "@id": "https://api.example.com/membership-products/2#/offers/1", "price": 90, "priceCurrency": "GBP", "url": "https://bookingsystem.example.com/membership/purchase/deep/link/8" } ] }, { "@type": "MembershipProduct", "@id": "https://api.example.com/membership-products/3", "name": "Family Membership", "description": "The family membership entitles the whole household to book a court to play tennis for up to 1 hour a day, 5 times a week, all year round for a £55 fee.", "membershipProductType": "https://openactive.io/MembershipPass", "duration": "P12M", "offers": [ { "@type": "Offer", "@id": "https://api.example.com/membership-products/3#/offers/1", "price": 55, "priceCurrency": "GBP", "url": "https://bookingsystem.example.com/membership/purchase/deep/link/8" } ] }, { "@type": "MembershipProduct", "@id": "https://api.example.com/membership-products/4", "name": "Junior Membership", "description": "Our junior membership is available for free to individuals under 16 and will enable young people to book and play tennis for 1 hour a day 5 times a week. This includes juniors playing with an adult.", "membershipProductType": "https://openactive.io/MembershipPass", "duration": "P12M", "ageRestriction": { "@type": "QuantitativeValue", "maxValue": 15 }, "offers": [ { "@type": "Offer", "@id": "https://api.example.com/membership-products/4#/offers/1", "price": 0, "url": "https://bookingsystem.example.com/membership/purchase/deep/link/8" } ] }, { "@type": "MembershipProduct", "@id": "https://api.example.com/membership-products/5", "name": "Female Concession Fitness", "description": "For females over 60 years of age. Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times", "genderRestriction": "https://openactive.io/FemaleOnly", "ageRestriction": { "@type": "QuantitativeValue", "minValue": 60 }, "membershipProductType": "https://openactive.io/MembershipSubscription", "offers": [ { "@type": "Offer", "price": 25, "priceCurrency": "GBP", "paymentFrequency": "http://openactive.io/ns#Monthly", "url": "https://bookingsystem.example.com/membership/purchase/deep/link/16" } ] } ] } ```

Advantages

Disadvantages

Proposal

Option 3 appears to have the best balance of working for independent session operators, facilities-based venues, and large leisure operators.

(However note that Option 2 or 4 might be more straightforward for smaller booking systems, so perhaps should be supported alongside or in place of Option 3. A consideration relating to this more pluralistic approach is that it creates additional complexity for Brokers that are aggregating across multiple Booking Systems - as they would need to dereference a MembershipProduct @id across both the Seller and the Place - though perhaps it also simplifies the Broker's task, as they can simply display the MembershipProducts at the level that they are included in the data - either within a Place or a Seller).

Following on from the modelling discussion above, a membership can therefore be represented in a feed:

{
  "next": "https://www.example.com/api/rpde/membership-products?afterTimestamp=1521565719&afterId=1402CBP20150217",
  "items": [
    {
      "state": "updated",
      "kind":  "MembershipProduct",
      "id": "1402CBP20150217",
      "modified": 1521565719,
      "data": {
        "@context": "https://openactive.io/",
        "@type":  "MembershipProduct",
        "@id": "https://api.example.com/membership-products/1",
        "name": "Off-Peak Fitness",
        "description": "Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times",
        "hostingOrganization": "https://api.example.com/sellers/1",
        "membershipProductType": "https://openactive.io/MembershipSubscription",
        "offers": [
          {
            "@type": "Offer",
            "price": 45,
            "priceCurrency": "GBP",
            "paymentFrequency": "http://openactive.io/ns#Monthly",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/4"
          }
        ]
      }
    },
    {
      "state": "updated",
      "kind":  "MembershipProduct",
      "id": "1402CBP20150218",
      "modified": 1521565719,
      "data": {
        "@context": "https://openactive.io/",
        "@type":  "MembershipProduct",
        "@id": "https://api.example.com/membership-products/2",
        "name": "Yoga Off-Peak 5 Session Pass",
        "description": "5 yoga sessions at select facilities at off-peak times",
        "hostingOrganization": "https://api.example.com/sellers/1",
        "membershipProductType": "https://openactive.io/MembershipPass",
        "offers": [
          {
            "@type": "Offer",
            "@id": "https://api.example.com/membership-products/2#/offers/1",
            "price": 90,
            "priceCurrency": "GBP",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/8"
          }
        ]
      }
    },
    {
      "state": "updated",
      "kind":  "MembershipProduct",
      "id": "1402CBP20150219",
      "modified": 1521565719,
      "data": {
        "@context": "https://openactive.io/",
        "@type":  "MembershipProduct",
        "@id": "https://api.example.com/membership-products/3",
        "name": "Family Membership",
        "description": "The family membership entitles the whole household to book a court to play tennis for up to 1 hour a day, 5 times a week, all year round for a £55 fee.",
        "hostingOrganization": "https://api.example.com/sellers/1",
        "membershipProductType": "https://openactive.io/MembershipPass",
        "duration": "P12M",
        "offers": [
          {
            "@type": "Offer",
            "@id": "https://api.example.com/membership-products/3#/offers/1",
            "price": 55,
            "priceCurrency": "GBP",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/8"
          }
        ]
      }
    },
    {
      "state": "updated",
      "kind":  "MembershipProduct",
      "id": "1402CBP20150220",
      "modified": 1521565719,
      "data": {
        "@context": "https://openactive.io/",
        "@type":  "MembershipProduct",
        "@id": "https://api.example.com/membership-products/4",
        "name": "Junior Membership",
        "description": "Our junior membership is available for free to individuals under 16 and will enable young people to book and play tennis for 1 hour a day 5 times a week. This includes juniors playing with an adult.",
        "hostingOrganization": "https://api.example.com/sellers/1",
        "membershipProductType": "https://openactive.io/MembershipPass",
        "duration": "P12M",
        "ageRestriction": {
          "@type": "QuantitativeValue",
          "maxValue": 15
        },
        "offers": [
          {
            "@type": "Offer",
            "@id": "https://api.example.com/membership-products/4#/offers/1",
            "price": 0,
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/8"
          }
        ]
      }
    },
    {
      "state": "updated",
      "kind":  "MembershipProduct",
      "id": "1402CBP20150221",
      "modified": 1521565719,
      "data": {
        "@context": "https://openactive.io/",
        "@type":  "MembershipProduct",
        "@id": "https://api.example.com/membership-products/5",
        "name": "Female Concession Fitness",
        "description": "For females over 60 years of age. Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times",
        "genderRestriction": "https://openactive.io/FemaleOnly",
        "ageRestriction": {
          "@type": "QuantitativeValue",
          "minValue": 60
        },
        "hostingOrganization": "https://api.example.com/sellers/1",
        "membershipProductType": "https://openactive.io/MembershipSubscription",
        "offers": [
          {
            "@type": "Offer",
            "price": 25,
            "priceCurrency": "GBP",
            "paymentFrequency": "http://openactive.io/ns#Monthly",
            "url": "https://bookingsystem.example.com/membership/purchase/deep/link/16"
          }
        ]
      }
    },
  ],
  "license": "https://creativecommons.org/licenses/by/4.0/"
}

The membership can also be referenced both within Places and opportunities to which it applies, such that it can be displayed when appropriate (for example as shown in the PDF flow attached here):

{
  "@type": "Places",
  ...
  "eligibleMembershipProduct": [
     "https://api.example.com/membership-products/3",
     "https://api.example.com/membership-products/4"
  ],
  ...
}
{
  "@type": "SessionSeries",
  ...
  "eligibleMembershipProduct": [
     "https://api.example.com/membership-products/3",
     "https://api.example.com/membership-products/4"
  ],
  ...
}
{
  "@type": "FacilityUse",
  ...
  "eligibleMembershipProduct": [
     "https://api.example.com/membership-products/3",
     "https://api.example.com/membership-products/4"
  ],
  ...
}

A broker would be expected to display the memberships only at the level that they are described in the data i.e.

A broker SHOULD NOT infer that a MembershipProduct is appropriate for an opportunity or Place unless is explicitly linked via eligibleMembershipProduct.

A membership with an Offer with an @id may be booked through the Open Booking API in the usual way using the MembershipProduct @id as the opportunity ID.

Modelling notes

Booking activities using membership pricing

Proposal

Using the patterns already included in the Customer Accounts API, the existing use of eligibleCustomerType beta, and referencing in the above discussion, Offers that are restricted to specific memberships may be included with opportunities.

"offers": [
    {
        "name": "Non-member price",
        "price": 4.5,
        "priceCurrency": "GBP"
    },
    {
        "name": "Member price",
        "price": 4.5,
        "priceCurrency": "GBP",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembershipProduct": [
           "https://api.example.com/membership-products/1",
           "https://api.example.com/membership-products/2"
        ],
    },
    {
        "name": "Private Member price",
        "eligibleCustomerType": "http://openactive.io/ns#Member",
        "eligibleMembershipProduct": [
           "https://api.example.com/membership-products/3",
           "https://api.example.com/membership-products/4"
        ],
    }
],

Getting current memberships of an authenticated Customer Account

Within the Customer Account API, it is possible to surface the MembershipProducts that are currently associated with a CustomerAccount.

Proposal

Add an additional property instanceOfMembershipProduct to Entitlement (the Entitlement is an instance of the MembershipProduct once it has been purchased).

{
  "@context": "https://openactive.io/",
  "@type": "CustomerAccount",
  "@id": "https://eg.com/customer-accounts/fdc14503-275e-46d3-9922-45b986c9f9aa",
  "identifier": "fdc14503-275e-46d3-9922-45b986c9f9aa",
  "accountNumber": "CA00000123",
  "customer": {
    ...
  },
  "hasHiddenEntitlements": false,
  "entitlement": [
    {
      "@type": "Entitlement",
      "validFrom": "2021-08-26T11:40:00+01:00",
      "validUntil": "2022-09-25T10:45:33+00:00",
      "instanceOfMembershipProduct": {
        "@type":  "MembershipProduct",
        "@id": "https://api.example.com/membership-products/1",
        "name": "Off-Peak Fitness",
        "description": "Unlimited access to the gym, fitness classes, swimming pool and select facilities at off-peak times",
        "membershipProductType": "https://openactive.io/MembershipSubscription"
      }
    }
  ],
  ...
}

Other considerations

Note the above proposals assume that the purchase of a subscription MembershipProduct is achieved through deep-linking, as the Open Booking API does currently support direct debit or recurring payments.

Additionally it’s worth noting that if a particular Place does not have any publicly visible opportunities (such as a private members club), then Place and crucially Seller data will not be available unless the Booking System implements a Places feed.

As always, thoughts and feedback on all of the above very welcome!

nathansalter commented 2 years ago

My concern is that we're getting to the point where we need a relatively large amount of feeds which all serve a single purpose of reducing data in the main feeds, but with data that doesn't actually have a high rate of churn (e.g. Sellers, Memberships Places etc.). I'm wondering if it would be easier from a broker/on-boarding perspective to have a single-feed (called something like /metadata) which returns all of this information:

{
  "next": "https://example.com/oa/metadata?afterId=83376c5c-6749-4679-bba2-5b30ae390fd1&afterTimestamp=1234567899",
  "items": [{
    "state": "updated",
    "kind": "Place",
    "data": {...}
  }, {
    "state": "updated",
    "kind": "Seller",
    "data": {...}
  }, {
    "state": "updated",
    "kind": "Membership",
    "data": {...}
  }]
}

This would be harder to implement from a booking system perspective (although I can't see that it would be a huge difficulty), but enables adding future items into this feed without requiring onboarding between the Broker & Booking System.