Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

use Osysharp.PrepaidCredits@0; · policy ManagesCredits => …; policy SpendsCredits => …; · GrantCredits(holder, 10, "Ten classes", expiresAt, reference) · SpendCredits(holder, 2, reason, reference) · RefundSpend(spend) · CreditsLeft(holder) · CreditStatementOf(holder) · CreditLiabilities()

`Osysharp.PrepaidCredits` keeps prepaid uses — a class pack, a ten-visit card, machine hours — as packs of credits a person holds. A spend takes from the pack that expires first, across packs when it must, all or nothing and never below zero, once per reference; it can be undone. Staff correct, extend, end, void and move credits with a reason. Every change is a line in a ledger the holder reads, packs warn before they expire, and the value still owed is one call.

preview1 example compiled by CIuikitcreditspayments

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

Summary#

use Osysharp.PrepaidCredits@0; keeps what people paid for in advance and use one at a time. The app says who looks after credits and who spends them for others:

policy ManagesCredits => IsOwner;     // grant, correct, extend, end, void, move — and the value owed
policy SpendsCredits => IsDesk;       // spend and undo for anybody

Then GrantCredits(person, 10, "Ten classes", expiresAt) when a pack is sold, and SpendCredits(person, 1, "Tuesday class", reference) when one is used.

Signature#

virtual policy ManagesCredits => false;     virtual policy SpendsCredits => false;     virtual policy SpendsOwnCredits => false;
virtual policy ActsForCreditHolder(Principal holder) => holder == user;
app.Credits = new CreditsSetup { ExpiryNoticeDays = 7, LowBalanceAt = 0, HoldersMayTransfer = false, Unit = "credit", Units = "credits" };

CreditPack GrantCredits(Principal holder, int count, string label, DateTime? expiresAt = null, string? reference = null,
                        string scope = "", decimal unitValue = 0, string reason = "");
CreditSpend? SpendCredits(Principal holder, int amount, string reason, string reference, string scope = "", Principal? forWhom = null);
CreditSpend? SpendCredit(Principal holder, string reason, string reference, string scope = "");
bool RefundSpend(CreditSpend spend, string reason = "");
void AdjustCredits(CreditPack pack, int remaining, string reason);       void ExtendCredits(CreditPack pack, DateTime? expiresAt, string reason = "");
bool ExpireCredits(CreditPack pack, string reason = "");                 bool VoidCredits(CreditPack pack, string reason = "");
CreditPack TransferCredits(CreditPack pack, int count, Principal to, string reason = "");
int CreditsLeft(Principal holder, string scope = "");    List<CreditPack> CreditPacksOf(Principal holder);
List<CreditEntry> CreditStatementOf(Principal holder, int take = 200);  List<CreditSpend> CreditSpendsOf(Principal holder);
List<CreditPack> CreditsExpiringWithin(Principal holder, int days);     List<CreditLiability> CreditLiabilities();

Description#

Which pack does a spend take from?#

The usable pack — not voided, not ended, with credits left — that expires first; a pack that never expires goes last; among equals, the oldest. A spend of several credits draws across packs in that order, and writes one ledger line per pack it touched. It is all or nothing: when the holder cannot spend that many, it answers null and nothing changes — the balance never goes below zero, whoever calls, however many spend at once.

What makes a spend safe to repeat?#

Its reference — a booking, a scan, a ticket. The same reference never spends twice: a repeat answers the spend it already made. A grant with a reference (an order line) is the same: the same reference answers the pack it made.

What is a scope?#

scope: "workshops" keeps a pack for one purpose: only a spend naming that scope draws from it, and an unscoped spend never touches it. CreditsLeft(person, "workshops") reads that purse. Osysharp.Memberships keeps each counted membership's visits this way.

Who may spend?#

SpendsCredits spends for anybody. SpendsOwnCredits lets a holder spend their own — an online booking — and those of the people ActsForCreditHolder says they act for: a parent for a child who never signs in (an account made with Osysharp.UserAccounts' MakeManagedAccount). forWhom: records who used a credit when it is not the holder — a child on a parent's pack. A function a kit or the app runs as the system (a workflow, a handler) may spend too.

What do staff change, and how is it kept?#

Every change is a line in CreditEntry — granted, spent, refunded, adjusted, expiry changed, expired, voided, given away, received — with the change, the balance after it in that scope, who made it and why. AdjustCredits requires a reason; nobody edits or deletes a line, and no grant opens a direct write to a balance: Remaining changes only inside the kit's functions (deny update Remaining except via …). TransferCredits moves credits to another person as a pack of theirs with the same expiry, scope and value; a holder may give their own when HoldersMayTransfer is on.

What happens before and at expiry?#

Each pack has a life of its own: ExpiryNoticeDays before it expires, with credits left, CreditsExpiringSoon is raised — the moment to tell its holder. At expiry what is left leaves the balance as an Expired line, and CreditsExpired says how many. Changing the expiry moves both; an ended pack is never brought back — grant a new one.

What is the value still owed?#

CreditLiabilities() sums, per scope, the credits unspent and unexpired, their value at what each was paid (unitValue), how many people hold them, and how many expire within 30 days — prepaid value the business still owes in service.

Which events does it raise?#

CreditsGranted, CreditsSpent, CreditsRefunded, CreditsRunningLow (at or under LowBalanceAt), CreditsRanOut, CreditsExpiringSoon, CreditsExpired, CreditsVoided, CreditsAdjusted, CreditsTransferred. The app hears one with [On] void Heard(CreditsRanOut e) { … } — a mail, an offer to buy another pack.

Which pages does it serve?#

/credits — the signed-in person's balance, packs about to expire, packs and statement. /staff/credits — the desk: find a person, use credits, undo a use, give a pack, correct, change an expiry, move, void. /staff/credits/owed — the value still owed, for those who manage credits. Each stands in the app's working shell, every colour and size a theme token; take one over by declaring a component of the same name, or place CreditsOverview, CreditsDesk or CreditsOwed on a page of your own.

How is a pack sold?#

Money moves through the app's payments (Osysharp.Payments, Osysharp.Shop): when an order is paid, grant the pack with the order line as its reference, so a repeated notice grants nothing twice, and its price per credit as unitValue.

Whose data is kept, and how does it go?#

What a spend or a change was for is Personal.Content. With Osysharp.Privacy, Osysharp.Privacy.PrepaidCredits — which arrives by itself (Bridges — a package that arrives when an app uses the two it completes) — counts, copies and erases a person's packs, spends and ledger.

Examples#

A makerspace selling machine hours by the pack#

app Makerspace {
  use Osysharp.Identity@0;
  use Osysharp.PrepaidCredits@0;
  use Osysharp.Ui;
  use Osysharp.Workflow;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.PrepaidCredits;

[Role] enum SpaceRole { Authenticator, Maker, Host }
policy IsHost => RoleGrant.Any(g => g.Member == user && g.Role == SpaceRole.Host);
policy IsMaker => RoleGrant.Any(g => g.Member == user && g.Role == SpaceRole.Maker);
policy ManagesCredits => IsHost;
policy SpendsCredits => IsHost;
policy SpendsOwnCredits => IsMaker;

app.Credits = new CreditsSetup { ExpiryNoticeDays = 14, LowBalanceAt = 2, Unit = "hour", Units = "hours" };

entity MachineBooking {
  [Required, MaxLength(80)] string Machine;
  [Required] Principal Maker;
  [Min(1)] int Hours = 1;
  CreditSpend? PaidWith;
  security { allow read, create when IsAuthenticated; }
}

/// A maker books the laser cutter, paying in machine hours.
[Server, Authorize(IsAuthenticated)]
public string BookMachine(string machine, int hours) {
  var me = CurrentPrincipal()!;
  var spend = SpendCredits(me, hours, machine, "booking:" + Guid.NewGuid(), "machines");
  if (spend == null) { return "Not enough machine hours left."; }
  new MachineBooking { Machine = machine, Maker = me, Hours = hours, PaidWith = spend };
  return "";
}

/// Running low: offer the next pack.
[On]
void OfferMoreHours(CreditsRunningLow e) { Log.Information("{Who} has {Left} hours left", e.Holder.DisplayName, e.Left); }

See also#

Related

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…

deny … except via

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

What may go past the caller's own rights — and what never does

A request always runs as the person who made it; there is no system mode, sudo or definer's rights a function can…

[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…

Remind (milestone reminders)

A reminder scheduled off a milestone. Its SCHEDULE is config in the header parens — `After` is the first fire (once, at…

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…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…