Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The OfferSales kit — sell a few simple offers online or across a counter, and hand over what was bought once

use Osysharp.OfferSales@1; · policy ManagesOffers => …; · policy SellsAtCounter => …; · StartPurchase(offer, providerKey, forPerson, quantity) · ConfirmPurchase(reference, token) · MarkPurchasePaid(p) · SellAtCounter(offer, holder, method) · StartCounterPurchase(offer, holder, counterKey) · RefundPurchase(p, reason, amount) · TakingsBetween(from, to) · [On] void H(PurchasePaid e)

`Osysharp.OfferSales` sells a handful of fixed-price offers — a ticket, a pass, a pack, a membership — online through any Osysharp.Payments provider, by transfer or pay-at-the-desk marked received, or at a counter (cash, a terminal, a reader, a QR code), for the buyer or somebody they buy for. Places that cannot be oversold, a per-person limit, a hold that lapses; `PurchasePaid` exactly once and `PurchaseRefunded` once per refund; the takings and a CSV. Not a shop.

preview1 example compiled by CIuikitpaymentssales

Source on GitHub: Osysharp.OfferSales — github.com/osysharp/offersales ↗ · Osysharp.OfferSales.Memberships — github.com/osysharp/offersales/memberships ↗ · Osysharp.OfferSales.PrepaidCredits — github.com/osysharp/offersales/prepaidcredits ↗ · Osysharp.Privacy.OfferSales — github.com/osysharp/privacy/offersales ↗

Summary#

use Osysharp.OfferSales@1; sells offers through the providers the app lists. The app says who sells:

policy ManagesOffers => IsManager;    // offers, prices, places; the takings; refunds (unless RefundsPurchases says otherwise)
policy SellsAtCounter => IsDesk;      // sell in person, mark a transfer or a pay-at-the-desk received

and hands over what was bought where it hears [On] void HandOver(PurchasePaid e) { … } — exactly once per purchase.

Signature#

virtual policy ManagesOffers => false;   virtual policy SellsAtCounter => false;   virtual policy RefundsPurchases => ManagesOffers;
virtual policy BuysFor(Principal holder) => holder == user;
public interface IOfferSalesRecipients { List<Principal> RecipientsFor(Principal buyer); }
app.OfferSales = new OfferSalesSetup { Providers = [ … IPaymentProvider … ], CounterPayments = [ … ICounterPayment … ],
                                   CounterMethods = ["Cash", "Card on the terminal"], Currency = "EUR", Country = "",
                                   HoldMinutes = 30, ManualHoldDays = 7, CounterMayDiscount = true, Seller = "" };

public entity Offer { string Key; string Name; string Description; long Price; long TaxRate; bool Active; int SortOrder;
                      DateTime? OnSaleFrom; DateTime? OnSaleUntil; int? Places; int? PerPerson; int MaxPerPurchase; }
PurchaseStart StartPurchase(Offer offer, string providerKey, Principal? forPerson = null, int quantity = 1, string returnUrl = "");
Purchase ConfirmPurchase(string reference, string token);       string ApplyOfferSalesEvents(List<PaymentEvent> events, string method);
Purchase MarkPurchasePaid(Purchase p);                         Purchase CancelPurchase(Purchase p, string reason = "Cancelled.");
Purchase SellAtCounter(Offer offer, Principal holder, string method, int quantity = 1, long discount = 0, string discountReason = "");
CounterStart StartCounterPurchase(Offer offer, Principal holder, string counterKey, int quantity = 1, long discount = 0, string discountReason = "");
Purchase CheckCounterPurchase(Purchase p);
PurchaseRefund RefundPurchase(Purchase p, string reason, long amount = 0);   // 0: all that is left
Takings TakingsBetween(DateOnly from, DateOnly to);           string PurchasesCsv(DateOnly from, DateOnly to);

Amounts are minor units (cents) throughout.

Description#

What is an offer?#

A derivable entity: an app adds its own kinds (entity GuidedTour : Offer { DateTime? StartsAt; … }) and restates a security { } block; the base's deny — an offer off sale is hidden from the public — carries to every kind (entity Sub : Base). Places caps how many are held or sold: a purchase holds them on OfferPlaces, one row per offer with a concurrency check, so two buyers racing for the last place cannot both have it, and a lapse, a cancellation or a whole refund gives them back. OfferRefusal says in a sentence why something cannot be bought now.

A purchase is created only by the kit's functions and moved only by its own workflow, PurchaseLife. Every report of money — the buyer returning (ConfirmPurchase asks the provider itself), the provider's signed event (OfferSalesPaymentEvents at a route the app declares → ApplyOfferSalesEvents), staff marking a manual payment received, a counter sale — is deposited into that run by the purchase's reference (Correlation — finding a run by a business key). The run checks the amount and moves to Paid once; PurchasePaid is raised on that move, so a repeated report changes nothing. A purchase not paid within HoldMinutes (online) or ManualHoldDays (a transfer) lapses to Cancelled, its places back on sale; a declined payment is Failed and may still be paid until then. A free offer is paid the moment it is taken.

At the counter#

SellAtCounter records money in hand by one of the app's CounterMethods and is paid at once; a discount takes a reason, which is kept. StartCounterPurchase begins a payment on a provider's device — a card reader, a QR code (ICounterPayment) — and CheckCounterPurchase asks it until it is paid. The sale records who sold it.

Refunds#

RefundPurchase gives back part or all of what is left. The PurchaseRefund row is written before the provider is asked (a unique sequence per purchase), so two refunds at once cannot both ask; the provider that took the money is asked — a device's payment through the online provider it RefundsThrough — and money taken by hand goes back by hand. PurchaseRefunded is raised once per refund with Whole true when all of it is back; the purchase is then Refunded.

Buying for somebody else#

StartPurchase(…, forPerson) buys for another person when BuysFor(holder) says so — a parent for a child. The page offers the people every IOfferSalesRecipients the app has answers. What was bought is handed to Purchase.For; the buyer pays and reads it too.

Granting a pack or a membership#

With Osysharp.PrepaidCredits, Osysharp.OfferSales.PrepaidCredits arrives by itself and adds CreditPackOffer (credits, validity, scope): paid, the pack is granted to whoever it was bought for under the purchase's reference; refunded in full, it is voided (The PrepaidCredits kit — prepaid uses, spent from the pack that expires first, never below zero). With Osysharp.Memberships, Osysharp.OfferSales.Memberships adds MembershipOffer (a plan): paid, the membership is sold at what was paid; refunded in full, it ends (The Memberships kit — what lets a person in: passes, memberships and visit cards).

Which pages does it serve?#

/buy (the offers; how many, for whom, how to pay), /purchases (with each receipt), /purchases/{reference}/{token} (where a provider sends the buyer back), /staff/counter, /staff/sales (the takings by way the money came in, every purchase, refunds, the CSV) and /staff/offers. Each stands in the app's working shell with theme tokens only; take one over by declaring a component of the same name, or place BuyOffers, OfferPriceList, MyPurchases, PurchaseReceipt, CounterSale, SalesDesk or OffersDesk on a page of your own.

Whose data is kept?#

With Osysharp.Privacy, Osysharp.Privacy.OfferSales arrives by itself (Bridges — a package that arrives when an app uses the two it completes): what a person bought or had bought for them, kept with the books on a legal obligation, counted, copied and erased when that ends.

Examples#

A café that sells a coffee card at the till#

app Cafe {
  use Osysharp.Identity@0;
  use Osysharp.Payments@0;
  use Osysharp.Csv@0;
  use Osysharp.OfferSales@1;
  use Osysharp.Storage;
  use Osysharp.Ui;
  use Osysharp.Workflow;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Payments;
using Osysharp.OfferSales;

[Role] enum CafeRole { Authenticator, Regular, Barista, Owner }
policy ManagesOffers => RoleGrant.Any(g => g.Member == user && g.Role == CafeRole.Owner);
policy SellsAtCounter => RoleGrant.Any(g => g.Member == user && g.Role == CafeRole.Barista);

app.OfferSales = new OfferSalesSetup {
  Providers = [ new ManualPayment { Key = "tab", Label = "Pay at the till" } ],
  CounterMethods = ["Cash", "Card on the terminal"], Currency = "EUR", Seller = "Corner Café",
};

/// A card of coffees — the café's own kind of offer.
entity CoffeeCard : Offer {
  [Min(1)] int Coffees = 10;
  security { allow read when IsAnonymous || IsAuthenticated; allow create, update when ManagesOffers; }
}

/// The stamps a regular holds — handed over when a card is paid, and once only.
entity Stamps {
  [Required("Whose stamps?")] Principal Holder;
  int Left = 0;
  [MaxLength(64)] string Reference = "";
  security { allow read when IsAuthenticated where Holder == user; }
}

[On]
void HandOverStamps(PurchasePaid e) {
  var card = CoffeeCard.FirstOrDefault(c => c.Id == e.Purchase.Offer.Id);
  if (card == null) { return; }
  new Stamps { Holder = e.Purchase.For, Left = card!.Coffees * e.Purchase.Quantity, Reference = e.Purchase.Reference };
}

See also#

Related

The PrepaidCredits kit — prepaid uses, spent from the pack that expires first, never below zero

`Osysharp.PrepaidCredits` keeps prepaid uses — a class pack, a ten-visit card, machine hours — as packs of credits a…

The Memberships kit — what lets a person in: passes, memberships and visit cards

`Osysharp.Memberships` sells what lets people in — a day ticket, a monthly membership, a card of ten visits, a family…

Correlation — finding a run by a business key

Let an inbound event find its run by a key the sender already knows — a tracking number, an invoice reference, an…

[Event] — what happens to a record, declared once

An event is a class marked `[Event]`: something that happens to a record, declared once by the code that owns the…

[On] — hear an event, after the commit that raised it

`[On]` on a function makes it a HANDLER of the event its one parameter is. Every handler of an event — in the app and…

settings a package asks its app for (app settings)

A package — or an app — declares a setting by marking a class `[AppSetting("Name")]`. The app that uses it fills it in…

deny … except via

Names the ONLY functions whose own code may change something a deny withholds: `deny update UnitsLeft except via…

entity Sub : Base

Derives one entity from another. The subtype is its own type with its own name, its own security and its own workflows…

Bridges — a package that arrives when an app uses the two it completes

A bridge is a package whose manifest says `completes A@0, B@0;`. An app that uses every package it completes gets it…

The Privacy kit — erasure that keeps only what the company must or may

`Osysharp.Privacy` answers a person's GDPR request — access, portability, erasure, restriction — per category of what…