The coupon/discount system (company/coupon/), the package-bundle system (company/package/), Product and its Feature union, and the standalone GroupPricing class.
Four classes work together, all under company/coupon/:
Coupon (coupon.ts) — the reusable discount definition (percent off, duration).PromotionalCode (promotionalCode.ts) — a specific redemption code tied to a coupon, optionally scoped to one member.Discount (discount.ts) — a record that a coupon was actually applied somewhere (a product, package, subscription, or checkout).CouponModifier (modifier.ts) — scopes a coupon to a specific target (product/service/class/tag) so it only applies there.Mirrors Stripe's coupon duration semantics.
| Field | Type | Notes |
|---|---|---|
id, companyID | string | |
name | string? | |
percentOff | number | |
duration | CouponDuration = "once" | "forever" | "repeating" | "once" = first invoice only, "forever" = life of subscription, "repeating" = durationInMonths months |
durationInMonths | number? | only meaningful when duration === "repeating" |
timesRedeemed | number | |
stripeCouponID | string? | full data only |
Full data (CouponData) adds createdAt/updatedAt over the lazy shape.
static create(company: Company, coupons: CreateCouponData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<Coupon[]>>CreateCouponData = { name: string; percentOff: number; duration: CouponDuration; durationInMonths?: number }.
Endpoint: /company/{cID}/coupons. viewHref: /company/{cID}/coupons/{id}.
Instance methods — the parent → children convenience-wrapper pattern used across the SDK:
coupon.promotionalCodes(opts: QueryRequest) // PromotionalCode.query filtered by couponID
coupon.modifiers(opts: QueryRequest) // CouponModifier.query filtered by couponID
coupon.discounts(opts: QueryRequest) // Discount.query filtered by couponIDA specific redemption code tied to a couponID, optionally scoped to one memberID.
| Field | Type | Notes |
|---|---|---|
id, companyID, couponID | string | |
memberID | string | null? | optional single-member scoping |
code | string | |
active | boolean | |
maxRedemptions | number | null? | |
timesRedeemed | number | |
restrictionFirstTimeTransation | boolean | field name as written in source (typo'd — missing the second s in Transaction; kept verbatim since it's the literal wire field) |
restrictionMinimumAmount | number | |
expiresAt | Date | null? | |
stripePromotionCodeID | string? | full data only |
static create(company: Company, codes: CreatePromotionalCodeData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<PromotionalCode[]>>Endpoint: /company/{cID}/promotionalCodes. viewHref: /company/{cID}/coupons/codes/{id}.
promotionalCode.coupon getter — lazily resolves and caches the parent Coupon from couponID.
promotionalCode.discounts(opts: QueryRequest)Discount.query by the code's own couponID — not a promotionalCodeID filter, since Discount (as modeled client-side pre-2024) originally had no promotionalCodeIDfield. This means it returns all discounts recorded against the code's parent coupon, not only ones redeemed through this specific code.A read-only-ish record of a coupon applied somewhere: exactly one of productID (required), packageID/subscriptionID/checkoutID/couponID/promotionalCodeID is meaningfully set to say where it landed.
| Field | Type | Notes |
|---|---|---|
id, companyID | string | |
productID | string | null? | required per the interface doc comment, though typed optional |
packageID | string | null? | |
subscriptionID | string | null? | |
checkoutID | string | null? | |
couponID | string | null? | |
promotionalCodeID | string | null? | |
amountOff | number | |
stripeDiscountID | string? | full data only |
static create(company: Company, products: CreateDiscountData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<Discount[]>>create (lowercase), matching the other coupon classes — the products parameter name on create() is a copy/paste leftover, not a hint about the payload shape (CreateDiscountData has no products field).Endpoint: /company/{cID}/discounts. viewHref: /company/{cID}/discounts/{id}.
Scopes a Coupon to a specific target: exactly one of productID/serviceID/classID/tagID is set.
| Field | Type | Notes |
|---|---|---|
id, companyID, couponID | string | |
productID | string | null? | |
serviceID | string | null? | |
classID | string | null? | |
tagID | string | null? |
static create(company: Company, coupons: CreateCouponModifierData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<CouponModifier[]>>Static creator is create (lowercase) — all four coupon classes use the same lowercase create/query casing, unlike some other domain classes elsewhere in the SDK that use Create/Query.
Endpoint: /company/{cID}/couponModifiers. viewHref: /company/{cID}/coupons/modifiers/{id}.
A bundle of products/services/class instances sold together at one price, split across two classes under company/package/. The relationship is one-directional: PackageLines point back at their Package via packageID, but neither Product nor Checkout references Package back.
| Field | Type | Notes |
|---|---|---|
id, companyID | string | |
name | string | null? | |
price | number | the actual sale price of the bundle |
description | string | null? | full data only |
valuedPrice | number | full data only — pre-discount sum of the lines' standalone prices, shown alongside price so the savings are visible |
static create(company: Company, packages: CreatePackageData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<Package[]>>CreatePackageData = { name?, description?, valuedPrice?, price? }.
Endpoint: /company/{cID}/packages. Has icon (lucide Package) and viewHref: /company/{cID}/packages/{id}.
pkg.lines(opts: QueryRequest) // PackageLine.query filtered by packageIDOne bundled item within a Package — references a Product, Service, or ClassInstance.
| Field | Type | Notes |
|---|---|---|
id | string | |
packageID | string | null? | |
productID | string | null? | exactly one of productID/serviceID/instanceID is normally set |
serviceID | string | null? | |
instanceID | string | null? | |
quantity | number | |
amount | number | |
discount | number | full data only — amount shaved off this line's standalone price when sold as part of the bundle |
CreatePackageLineData = Pick<LazyPackageLineData, "quantity"> & { packageID: string; productID?; serviceID?; instanceID?; amount?; discount? }. instanceID is modeled on the type but not exposed in the create form — that form only lets a caller pick Product or Service.
static create(company: Company, lines: CreatePackageLineData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<PackageLine[]>>Endpoint: /company/{cID}/packages/lines.
Related-entity getters, each cached in a private field so the same instance is returned on every call:
line.package // Package | undefined, from packageID
line.product // Product | undefined, from productID
line.service // Service | undefined, from serviceID
line.instance // ClassInstance | undefined, from instanceIDA sellable item (physical or plan). company/product/product.ts. What actually happens on purchase — one-time charge, subscription, group grant, tag grant, credits, document generation — is layered on separately via Feature rows, not stored directly on the product.
| Field | Type | Notes |
|---|---|---|
id, companyID | string | |
name | string | |
description | string? | |
visibleOnline | boolean | |
price | number? | "Sticker" field only — actual purchase pricing lives on the product's pricing-type Feature(s) |
sku | string? | |
stock | number? | |
stripeProductID | string? | |
createdAt / updatedAt | Date | present on LazyProductData itself — ProductData is currently identical to it, kept as its own type for parity with other resources' lazy/full split |
static create(company: Company, products: CreateProductData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static query(company: Company, data: QueryRequest): Promise<SimpleResult<Product[]>>CreateProductData = Pick<LazyProductData, "name" | "visibleOnline"> & { description?, price?, stock?, sku? } — a product can be created bare (name + visibility) with pricing/behavior added afterward via Feature rows.
Endpoint: /company/{cID}/products. Has icon (lucide Package) and viewHref: /company/{cID}/products/{id}.
Parent → children convenience wrappers (same pattern as coupon.promotionalCodes()):
product.features(opts: QueryRequest) // Feature.query filtered by productID
product.GetFeatures({ limit?, offset?, lazy? }) // paginated-defaults variant, same filter
product.GetSubscriptions({ limit?, offset?, lazy? }) // Subscription.query filtered by productID
product.GetDiscounts({ limit?, offset?, lazy? }) // Discount.query filtered by productID
product.GetModifiers({ limit?, offset?, lazy? }) // CouponModifier.query filtered by productID
product.GetListOfTagAssignments({ limit?, offset?, lazy? }) // TagAssignment.query, resourceType "product"Media (product photo, via the generic per-resource media system — see the overview page):
product.UploadPhoto(file: File | Blob, contentType?: string)
product.GetPhoto(): Promise<string | undefined>Tagging:
product.AssignTag(tag: Tag) // creates a TagAssignment { tagID, resourceID: this.id, resourceType: "product" }company/product/feature.ts. A purchase-time effect attached to a Product — grants pricing, group membership, a tag, credits, or a generated document when the product is bought. A single product can carry several features at once (e.g. a pricing feature plus a group feature that grants access when that price resolves).
export type FeatureType = "pricing" | "group" | "tag" | "credits" | "document";
export type BillingUnit = "day" | "week" | "month" | "year"; // reused by PayrollScheduleFeatureData is a client-side flattening of the discriminated union below into one shape — only the field group matching featureType is meaningful for a given row. CreateFeatureDatamirrors this: one flat "optional everything" interface (not a true TS union) keyed by featureType, so callers populate only the fields relevant to the chosen type; Feature.create/save branch on featureType internally to pick out the relevant subset before sending to the backend — passing fields for the wrong type is a silent no-op, not a type error.
resolveAfterAmount/resolveAfterUnit and permanent — that resolve to exactly one of three mutually exclusive modes: (1) resolveAfterAmount/resolveAfterUnit set → expires a fixed duration after grant; (2) permanent: true→ never expires, and is skipped from invoice tracking entirely regardless of any recurring pricing on the product; (3) neither set (the default) → tracks the earliest current period-end among the product's recurring pricing features. A PricingFeature itself has no permanent field — it can only be immediate (neither field set) or resolve-after — and a DocumentFeature has no resolveAfterAmount/resolveAfterUnit — it only expires (i.e. gets re-issued) by tracking or is permanent.PricingFeature (featureType: "pricing") — sets the price (one-time or recurring) the buyer pays.
| Field | Type |
|---|---|
amount | number |
stripePriceID | string |
recurring | { unit: BillingUnit; amount: number }? |
resolveAfterAmount | number | null? |
resolveAfterUnit | BillingUnit | null? |
GroupFeature (featureType: "group") — adds the buyer to groupID on purchase.
| Field | Type |
|---|---|
groupID | string |
resolveAfterAmount / resolveAfterUnit | as above |
permanent | boolean | null? |
TagFeature (featureType: "tag") — applies tagID to the buyer on purchase.
| Field | Type |
|---|---|
tagID | string |
resolveAfterAmount / resolveAfterUnit | as above |
permanent | boolean | null? |
CreditsFeature (featureType: "credits") — grants a Credit on purchase. See Members for Credit's own realm rules, which this mirrors exactly.
| Field | Type | Notes |
|---|---|---|
classID / classTagID | string? | class realm |
serviceID / serviceTagID | string? | service realm |
doorID / locationID | string? | door/location realm |
amount | number | null? | null/unset means the granted credit is unlimited — no usage cap, never exhausted |
giftable | boolean | |
resolveAfterAmount / resolveAfterUnit | as above | |
permanent | boolean | null? |
The realm fields scope the credit to exactly one of class/service/door-location, or none for a fully generic credit — enforced server-side by validateCreditRealm(); the frontend does not re-validate this.
DocumentFeature (featureType: "document") — generates a MemberDocument from documentTemplateID for the buyer on purchase.
| Field | Type |
|---|---|
documentTemplateID | string |
permanent | boolean | null? |
No resolveAfterAmount/resolveAfterUnit— unlike group/tag/credits grants, a document doesn't expire on a fixed duration. It's still subject to the other two expiry modes shared with the rest of the feature types though: permanent: truemeans the issued document is never re-issued regardless of any recurring pricing on the product, and unset (the default) means it's re-issued whenever the earliest current period-end among the product's recurring pricing features rolls over.
static async create(company: Company, features: CreateFeatureData[]): Promise<EventData<string[]>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static async query(company: Company, data: QueryRequest): Promise<SimpleResult<Feature[]>>Feature.create accepts an array (the underlying param is always an array — pass a single-element array for one feature):
await Feature.create(company, [{
featureType: "pricing",
productID: product.id,
amount: 49.99,
recurring: { unit: "month", amount: 1 },
resolveAfterAmount: 1,
resolveAfterUnit: "month",
}]);
await Feature.create(company, [{ featureType: "group", productID: product.id, groupID: "..." }]);
await Feature.create(company, [{ featureType: "tag", productID: product.id, tagID: "..." }]);
// credits: grants a Credit scoped to at most one realm — class, service,
// or door/location — or none for a fully generic credit
await Feature.create(company, [{ featureType: "credits", productID: product.id, classID: "..." }]);
await Feature.create(company, [{ featureType: "credits", productID: product.id, serviceTagID: "..." }]);
await Feature.create(company, [
{ featureType: "pricing", productID: product.id, amount: 49.99 },
{ featureType: "group", productID: product.id, groupID: "..." },
]);Endpoint: /company/{cID}/products/features. save()/create() both branch on f.featureType, guarding each non-pricing variant's required field (groupID/tagID/documentTemplateID) with a 400 short-circuit if missing.
export function isPricingFeature(f: FeatureData): f is FeatureData & PricingFeature
export function isGroupFeature(f: FeatureData): f is FeatureData & GroupFeatureisTagFeature/isCreditsFeature/isDocumentFeature; narrow those manually via f.featureType === "tag" etc.BillingUnitOptions — label/value pairs for BillingUnit ("Day"|"Week"|"Month"|"Year").
FeatureTypeOptions — one entry per FeatureType with title, value, icon (a rendered lucide icon element), and description, used by the feature-type picker in the create-feature form.
Return falsy/empty defaults for non-pricing features rather than throwing:
feature.IsRecurring: boolean // !!this._data?.recurring
feature.RecurringUnit: BillingUnit | ""
feature.RecurringAmount: number // defaults to 0Each cached in a private field:
feature.product // Product | undefined, from productID
feature.group // Group | undefined, from groupIDcompany/groupPricing.ts. A standalone class that replaced the old per-class AddGroupPricing/GetGroupPricing/RemoveGroupPricing methods, shared across the class/service pricing types.
| Field | Type | Notes |
|---|---|---|
id | string | |
companyID | string? | |
groupID | string | |
classID | string? | exactly one of classID/serviceID is set — determines which pricing realm/endpoint this row targets |
serviceID | string? | |
discountType | DiscountType = "free" | "percent" | "fixed" | free waives the price entirely |
discountAmount | number? | required (in practice) for percent/fixed |
IsServicePricing(): boolean // this._data?.serviceID != undefined
IsClassPricing(): boolean // this._data?.classID != undefinedstatic async create(company: Company, pricingType: "services" | "classes", data: CreateGroupPricingData[]): Promise<SimpleResult<EventData>>
async save(): Promise<SimpleResult<EventData>>
async delete(): Promise<SimpleResult<EventData>>
static async query(company: Company, data: QueryRequest): Promise<SimpleResult<GroupPricing[]>>create() requires the explicit pricingType parameter (since a not-yet-constructed row has no classID/serviceID to infer the endpoint from), while save()/delete()/fetch() derive it from the instance's own data via a protected GetTypeEndpoint() helper — which throws if neither classID nor serviceID is set on the instance. query() itself posts to the endpoint-agnostic /company/{cID}/groupPricing (no services/classes segment), unlike create/save/delete/fetch.Endpoints: /company/{cID}/services/groupPricing or /company/{cID}/classes/groupPricing (create/save/delete/fetch, chosen per-row); /company/{cID}/groupPricing (query).
Related-entity getters, each cached in a private field:
pricing.group // Group | undefined, from groupID
pricing.service // Service | undefined, from serviceID
pricing.class // Class | undefined, from classID