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

> `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.

<!-- id: ui-offer-sales-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/offer-sales-kit/ -->

## Summary        {#summary}
`use Osysharp.OfferSales@1;` sells offers through the providers the app lists. The app says who sells:

```osy syntax
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      {#signature}
```osy syntax
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    {#description}

### What is an offer?   {#offers}
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](https://osysharp.com/reference/entity/inheritance/)). `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.

### How does a purchase become paid — once?   {#paid}
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](https://osysharp.com/reference/workflow/correlate/)). 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   {#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   {#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   {#for}
`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   {#grants}
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](https://osysharp.com/reference/ui/prepaid-credits-kit/)). 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](https://osysharp.com/reference/ui/memberships-kit/)).

### Which pages does it serve?   {#pages}
`/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?   {#privacy}
With Osysharp.Privacy, `Osysharp.Privacy.OfferSales` arrives by itself ([Bridges — a package that arrives when an app uses the two it completes](https://osysharp.com/reference/project/bridges/)): 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       {#examples}

### A café that sells a coffee card at the till   {#example-cafe}
```osy title="a community café: a coffee card sold at the till, its stamps handed over when paid" test app=ui-offer-sales-kit
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       {#see-also}
- [The PrepaidCredits kit — prepaid uses, spent from the pack that expires first, never below zero](https://osysharp.com/reference/ui/prepaid-credits-kit/) — a pack of credits as an offer, granted when paid
- [The Memberships kit — what lets a person in: passes, memberships and visit cards](https://osysharp.com/reference/ui/memberships-kit/) — a membership plan as an offer, sold when paid
- [Correlation — finding a run by a business key](https://osysharp.com/reference/workflow/correlate/) — how a payment finds its purchase by reference
- [entity Sub : Base](https://osysharp.com/reference/entity/inheritance/) — an app's own kinds of offer
- [The Privacy kit — erasure that keeps only what the company must or may](https://osysharp.com/reference/security/privacy-kit/) — a person's purchases in their privacy requests
- **`kits/offer-sales/tests/`** — every behaviour above as a test, the screens under `--pixels`
