Building on an add-on
Ce contenu n’est pas encore disponible dans votre langue.
An add-on can define a shape: the tables a job needs, with the rules Adminium keeps on them.
The Invoices & Receipts add-on (invoices) defines invoice@1: a document, its lines and its
payments, with gapless numbers, exact totals in the currency’s decimals, a balance that never goes
below zero, a draft–sent–void life, reminder emails and printed invoices and receipts.
An app that sends invoices does not have to invent any of that. It builds its own tables on the shape and adds what is its own: its clients, its projects, its portal. This page walks through such an app, a small studio that bills its clients. Every field it uses is listed in the manifest reference.
1. Require the add-on
Section titled “1. Require the add-on”The shape belongs to the add-on, so the app requires it:
"compatibility": { "minAdminiumVersion": "0.3.1" },"addOns": { "requires": [{ "key": "invoices", "range": ">=1.0.3", "reason": { "en-US": "Invoices, quotes and receipts are made by this add-on.", "de-DE": "Rechnungen, Angebote und Quittungen macht dieses Add-on." } }]}Installing the app then installs or connects the add-on first, before the app’s own tables, and
the add-on cannot be removed, switched off, or updated outside your range while the app is
installed. Uninstalling the app keeps the add-on. The range is the add-on versions your
tables were built against. Raise minAdminiumVersion to the release that reads the fields on this
page: an older server then asks the operator to upgrade instead of calling the manifest invalid.
See Add-ons.
2. Build a table on each part
Section titled “2. Build a table on each part”invoice@1 has three parts: document, lines and payments. The app builds one table on each,
naming the shape and the part:
{ "ref": "invoices", "builtOn": "invoices/invoice@1", "part": "document", "columns": [ … ], "states": { … } },{ "ref": "invoice_lines", "builtOn": "invoices/invoice@1", "part": "lines", "columns": [ … ] },{ "ref": "payments", "builtOn": "invoices/invoice@1", "part": "payments", "columns": [ … ] }Each table spells out its part: every column, with the same type, nullability, enum values,
length, scale, uniqueness, default and rules, and the part’s states. The add-on’s own
manifest.json, under addOn.shapes, is where you copy them from. Spelling them out keeps every
check of your app a function of your manifest alone: your CI, an upload and the catalogue need
nothing of the add-on to judge it.
Two things change as you copy. A part names the other parts by their part names; your table names your tables instead:
| The part says | Your table says |
|---|---|
"references": "document" |
"references": "invoices" |
"rollup": { "from": "lines", … } |
"rollup": { "from": "invoice_lines", … } |
"children": { "lines": { … } } in the states |
"children": { "invoice_lines": { … } } |
And where a part refers to a part of another of the add-on’s shapes (a line that came from a
quote: "references": "quote@1/document"), your app builds a table on that part too
("builtOn": "invoices/quote@1", "part": "document"), and the column refers to it.
Here is part of the document part as the invoices table spells it:
{ "ref": "invoices", "builtOn": "invoices/invoice@1", "part": "document", "label": { "en-US": "Invoice", "de-DE": "Rechnung" }, "labelPlural": { "en-US": "Invoices", "de-DE": "Rechnungen" }, "keyField": "number", "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "number_seq", "type": "int", "nullable": true, "rules": { "sequence": { "gapless": true, "startSetting": { "addOn": "invoices", "setting": "number_start_invoice" } } } }, { "ref": "number", "type": "text", "maxLength": 24, "nullable": true, "unique": true, "rules": { "format": { "from": "number_seq", "prefixSetting": { "addOn": "invoices", "setting": "prefix_invoice" }, "pad": 4 } } }, { "ref": "status", "type": "enum", "enum": ["draft", "sent", "void"], "default": "draft" }, { "ref": "currency", "type": "text", "maxLength": 3, "nullable": true, "rules": { "default": { "from": "connection.currency" } } }, { "ref": "tax_rate", "type": "decimal", "scale": 3, "nullable": true, "rules": { "default": { "from": { "addOn": "invoices", "setting": "default_tax_rate" } } } }, { "ref": "subtotal", "type": "decimal", "scale": "currency", "nullable": true, "rules": { "rollup": { "from": "invoice_lines", "via": "document_id", "sum": "amount" } } }, { "ref": "tax", "type": "decimal", "scale": "currency", "nullable": true, "rules": { "formula": { "round": { "div": [{ "mul": ["subtotal", { "coalesce": ["tax_rate", 0] }] }, 100] } } } }, { "ref": "total", "type": "decimal", "scale": "currency", "nullable": true, "rules": { "formula": { "add": [{ "coalesce": ["subtotal", 0] }, { "coalesce": ["tax", 0] }] } } }, … ] }What those rules give you, with no code in the app:
- Numbers.
number_seqis the next number of an unbroken series, taken inside the write that creates the invoice, starting at the add-on’snumber_start_invoice.numberis the same number as text with the add-on’s prefix,INV-0042. The studio changes the prefix and the first number in the add-on’s settings. See Numbers without gaps. - Currency. A new invoice takes the connection’s currency, and every money column keeps that currency’s decimals: none for JPY, three for KWD. See Decimal places.
- Totals. Each line’s
amountis a formula over its quantity, rate and discount; the invoice’ssubtotaladds the lines up;taxandtotalare formulas over it. The arithmetic is exact, and the same on every database. See Formulas. - Balance.
paidadds up the payments that are not voided and keepsbalancebeside it; a payment that would take the balance below zero is refused. See Totals and balances.
3. Add what is yours
Section titled “3. Add what is yours”Your table may carry columns of its own, beside the part’s. The studio links each invoice to a client and a project, and lets a client say “I’ve paid”:
{ "ref": "client_id", "type": "fk", "references": "clients" },{ "ref": "project_id", "type": "fk", "references": "projects", "nullable": true },{ "ref": "client_paid_at", "type": "timestamptz", "nullable": true }On the part’s own columns you may relabel, and add rules that label or narrow: enumLabels,
personal, validation, required, options, notAfter and notBefore. You may also put a copy in front of a column
the part fills from a setting, so a client’s own tax rate (a tax_rate on the app’s clients
table) comes first and the add-on’s default rate answers when the client has none:
{ "ref": "tax_rate", "type": "decimal", "scale": 3, "nullable": true, "rules": { "copy": { "via": "client_id", "from": "tax_rate" }, "default": { "from": { "addOn": "invoices", "setting": "default_tax_rate" } } } }Anything else about a part’s column (its type, a rule that decides its value) stays exactly as
the part declares it. The install checks this against the add-on it will really run on, and
refuses a table that differs with SHAPE_MISMATCH, naming the column, before anything is written.
4. Keep the part’s states
Section titled “4. Keep the part’s states”The document part declares the invoice’s life: a draft is sent once it has a line and a total
above zero, a sent invoice with nothing paid may be voided, and a sent or void invoice is locked
except for a few columns. The lines are locked with it, and payments are recorded only against a
sent invoice. See States.
Your table spells out the same states, and may add to them in five ways: more columns that stay
writable in lock.except (its own), roles on a move, more child tables in children, more
of its own columns in a child’s clearOnCreate, and, on a child, a release and lockLinked
over columns you added to it (a line’s link to the time it bills, emptied once the invoice is void;
see states). The studio keeps voiding a sent invoice for its
managers, and a recorded payment clears the client’s “I’ve paid”:
"states": { "column": "status", "initial": "draft", "moves": { "draft": [{ "to": "sent", "requires": { "children": { "invoice_lines": 1 }, "where": [{ "column": "total", "gt": 0 }] } }, "void"], "sent": [{ "to": "void", "requires": { "where": [{ "column": "paid", "eq": 0 }] }, "roles": ["manager"] }] }, "lock": { "when": ["sent", "void"], "except": ["due_on", "ladder", "void_reason", "client_paid_at"] }, "children": { "invoice_lines": { "via": "document_id", "lock": true }, "payments": { "via": "document_id", "parentIn": ["sent"], "clearOnCreate": ["client_paid_at"] } }, "noDelete": { "when": "numbered" }}5. Send the shape’s emails
Section titled “5. Send the shape’s emails”A shape can send email: invoice@1 sends the invoice when it is sent, three held reminders
after its due date, and a receipt for each payment. An app built on the shape sends them too, so
its outbox has a kind, a producer and a template for each kind the
shape’s outbox sends, with the shape’s producers as the starting point:
"outbox": { "table": "messages", "columns": { "kind": "kind", "status": "status", "to": "to", "due": "due", "skipReason": "skip_reason", "bodyOverride": "body_override", "approvedBy": "approved_by", "sentAt": "sent_at", "error": "error" }, "links": { "invoice": "invoice_id", "payment": "payment_id", "client": "client_id" }, "recipient": { "via": "client_id", "table": "clients", "email": "email", "name": "contact_name" }, "kinds": { "invoice-sent": "studio-invoice-sent", "invoice-rung-1": "studio-invoice-rung-1", … }, "producers": [ { "kind": "invoice-sent", "link": "invoice_id", "onChange": { "table": "invoices", "column": "status", "to": "sent" } }, { "kind": "invoice-rung-1", "link": "invoice_id", "hold": true, "onChange": { "table": "invoices", "column": "status", "to": "sent" }, "due": { "date": "due_on", "at": "09:00", "days": { "setting": { "addOn": "invoices", "setting": "ladders" }, "byColumn": "ladder", "index": 0 } }, "supersede": "rungs", "dropWhen": [{ "column": "balance", "lte": 0, "reason": "paid" }, { "column": "status", "eq": "void", "reason": "void" }] }, … ]}A reminder is written held: the studio reads it, may reword it, and approves it or skips it. A
later reminder that comes due overtakes an earlier one not yet sent, and paying or voiding the
invoice drops the ones still waiting. See Held messages.
Write the templates in the app’s own words, with the variables listed under Variables in An app’s emails. The add-on’s own templates use other variable names: do not copy them.
The outbox table’s kind enum lists every kind, its status enum includes held, and its links
are nullable foreign keys. A template may carry the invoice as an attachment with
"attach": { "kind": "invoice", "link": "invoice" }.
6. Print invoices and statements
Section titled “6. Print invoices and statements”The shape’s document profiles (an invoice, a receipt) are made for your tables when the app is
installed, with their real names, and removed when it is uninstalled. A printed invoice also wants
the client’s company and address, which are your columns, not the part’s. A documents entry of
the same kind on the same table adds them to the shape’s profile:
"documents": [ { "kind": "invoice", "addOn": "invoices", "table": "invoices", "name": { "en-US": "Invoice" }, "mapping": { "customerName": { "via": "client_id", "column": "company" }, "customerLines": { "via": "client_id", "column": "address" } } }, { "kind": "statement", "addOn": "invoices", "table": "clients", "name": { "en-US": "Statement" }, "mapping": { "customerName": { "column": "company" } }, "statement": { "documents": { "table": "invoices", "via": "client_id", "date": "issued_on", "amount": "total", "number": "number", "where": { "column": "status", "in": ["sent", "void"] } }, "payments": { "table": "payments", "via": "client_id", "date": "paid_on", "amount": "amount", "unless": "voided" } } }]A slot the add-on gives a default is filled when nothing else fills it, even when its column is
mapped but empty on the row: where the issue date’s default is now, a draft with no issue date
yet is dated the day it is drawn, on the venue’s clock, rather than refused as unmapped. See the
slot defaults under Documents.
The studio’s own screens print a document with POST /api/v1/apps/studio/documents/render,
naming the kind, the table’s ref and the row: { "kind": "invoice", "ref": "invoices", "pk": { "id": 42 } }.
The statement lists a client’s sent invoices and payments over a period (everything, this year or
the last twelve months), with an opening balance and a running balance. It is issued on the day
it is drawn. Each source’s via points at the client, so the app adds a client_id to its payments table,
copied from the invoice the payment is for:
{ "ref": "client_id", "type": "fk", "references": "clients", "nullable": true, "rules": { "copy": { "via": "document_id", "from": "client_id", "mode": "always" } } }See Documents.
A document that prints a money code, a gift card’s, is drawn when asked and never kept, so it is not in the documents register. See Documents that print a money code.
7. Open a portal for clients
Section titled “7. Open a portal for clients”A client signs in with a link emailed to their address, sees their sent invoices with the lines and payments of each, opens the printed invoice, and says “I’ve paid” once:
"publicAccess": [ { "table": "clients", "methods": ["GET"], "select": ["contact_name"], "claim": { "verify": "email-link", "email": "email" }, "humanCheck": true }, { "table": "invoices", "methods": ["GET", "PATCH"], "level": "verified", "claimedBy": { "table": "clients", "column": "client_id" }, "filters": [{ "column": "status", "op": "in", "value": ["sent", "void"] }], "select": ["number", "status", "total", "balance", "due_on", "client_paid_at"], "writable": ["client_paid_at"], "writableWhen": { "client_paid_at": [null] }, "documents": ["invoice", "statement"] }, { "table": "invoice_lines", "methods": ["GET"], "level": "verified", "visibleWith": { "table": "invoices", "via": "document_id" }, "select": ["description", "qty", "rate", "amount"] }, { "table": "payments", "methods": ["GET"], "level": "verified", "visibleWith": { "table": "invoices", "via": "document_id" }, "select": ["number", "amount", "paid_on"], "documents": ["receipt"] }]The lines and payments are reached through the invoice entry, so a draft’s lines are as hidden as the draft. Nothing a client writes can touch a number, a total or a balance: those are values Adminium decides. See Public access and Rows visible with their parent.
8. Ship sample data
Section titled “8. Ship sample data”Sample invoices must not take numbers from the real series, or the studio’s first real invoice
would not be number one. A sample row spells every gapless number null:
{ "ref": "invoices", "rows": [ { "@label": "inv-1", "client_id": { "@ref": "ada" }, "status": "sent", "number_seq": null, "number": "SAMPLE-1", "issued_on": { "@month": -1, "@dom": 3 }, "due_on": { "@month": -1, "@dom": 17 } }] }Adding the sample is refused while a row gives a gapless column a number or leaves it out. See Sample data.
9. Check it before you release
Section titled “9. Check it before you release”Run validateManifest from the @adminiumjs/manifest package in your CI. It reads your manifest
alone, and refuses one whose names do not add up: a rule’s column the table lacks, a formula
reading a column that holds no number, a state that is not a value of the state column, a
builtOn whose add-on the app does not require. It also returns warnings, advice that never
refuses a manifest; see Validation.
Get the add-on’s manifest
Section titled “Get the add-on’s manifest”Every name an app builds on (a shape’s parts and columns, a ledger’s actions and inputs) is in
the add-on’s own manifest: manifest.json inside the add-on’s package. With the package file at
hand:
tar -xzOf <folder>/invoices-<version>.tgz package/manifest.json > invoices.manifest.jsonA package is downloaded from the add-on’s page on adminium.dev, or from
https://downloads.adminium.dev/add-ons/<key>/<key>-<version>.tgz, with its sha512- fingerprint
saved beside it as <key>-<version>.tgz.integrity. Keep the two files in one folder: that folder
is what adminium app try --add-ons <folder> reads.
An add-on that keeps its own tables
Section titled “An add-on that keeps its own tables”Invoices & Receipts gives your app a shape to build its own tables on. Inventory (inventory)
does not: it keeps its own tables (items, places, stock levels, movements) and a ledger, stock.
An app that uses it builds no stock table. It writes three things:
- A link. A column of your table that holds the key of one of the add-on’s rows:
"rules": { "addOnLink": { "addOn": "inventory", "table": "items" } }on anintcolumn. No foreign key is made, so the app installs with or without the add-on. - A rule.
postingson your table: when a row is saved, or moves to a state, it is handed to one of the ledger’s actions, which takes the stock. - A grant.
tableson your role, so the person who fills the link can read the add-on’s rows to pick one. See “Your app’s roles on an add-on’s tables” below.
Every name in the rule is the add-on’s: the ledger, the action, and each input the action takes
are under addOn.ledgers[].actions in its manifest. Copy them from there. The install checks
each one against the add-on it runs on and refuses a rule that names an input the action has not,
or leaves out one it needs.
Require it, or suggest it. Under requires, the add-on is installed with the app, and the
rule always runs. Under suggests, the app runs without it: give the rule "needs": "<feature>"
and declare that feature under addOns.features. The rule is then live only while the add-on is
installed, connected to the app and switched on. A row saved while it is not live posts nothing,
and is not caught up later.
Sample rows. The app’s sample data may add rows to the add-on’s tables (items for the clinic’s shelf) in a second file, and name the add-on’s own sample rows by their labels: Rows for an add-on the app names.
What try shows. adminium app try --add-ons <folder> installs the add-on, then the app, and
loads the sample data. A sample row never posts, so try proves the rule is accepted, not that
stock moves. To see stock move, run the app, receive some stock in Inventory, and save one row.
Take stock when a row is saved
Section titled “Take stock when a row is saved”A clinic records what each visit used. A line names an item of Inventory and a quantity; the
stock is taken when the visit is marked seen and put back if it goes back to booked or is
cancelled.
{ "ref": "visit_supplies", "label": { "en-US": "Supply used" }, "labelPlural": { "en-US": "Supplies used" }, "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "visit_id", "type": "fk", "references": "visits" }, { "ref": "item_id", "type": "int", "nullable": true, "rules": { "addOnLink": { "addOn": "inventory", "table": "items" } } }, { "ref": "qty", "type": "decimal", "scale": 3 } ], "postings": [ { "id": "stock", "into": { "addOn": "inventory", "ledger": "stock", "action": "use-item" }, "via": "visit_id", "post": { "on": { "column": "status", "in": ["seen"] } }, "reverse": { "on": { "column": "status", "from": ["seen"], "in": ["booked", "cancelled"] } }, "map": { "item": "item_id", "quantity": "qty" } } ]}{ "requires": [{ "key": "inventory", "range": ">=1.0.8", "reason": { "en-US": "Inventory keeps the stock a visit uses." } }] }"tables": [{ "addOn": "inventory", "table": "items", "actions": ["read"], "limit": { "readable": ["name", "sku"] } }]viamakes the lines follow the visit:statusis the visit’s column, and when the visit becomesseenevery line is handed over in one call.use-itemtakesitemandquantity. It also takesplace,batchand a few more, all optional: with noplace, stock comes from the default place in Inventory’s settings.- The app’s
minAdminiumVersionis0.3.18or later.
To take every line from one shelf the practice chooses once, keep the shelf on the settings row
and let each line default to it. Both columns carry the same addOnLink, and the line maps it as
place:
{ "ref": "place_id", "type": "int", "nullable": true, "rules": { "addOnLink": { "addOn": "inventory", "table": "places" }, "default": { "from": { "table": "settings", "column": "supplies_place_id" } } } }A line somebody gives a place keeps it. While Inventory is not connected, the default fills nothing and no save is refused for it.
The other way: a row that has a Stock tab. A treatment always uses the same supplies. Staff
list them once, on the treatment’s Stock tab in the dashboard, and the action use takes all
of them:
"map": { "what": "treatment_id", "quantity": "times" }what is a row: the row the rule is on ({ "row": true }), or the row an fk column of it
points at, as here. No link column and no grant are needed: nobody picks an item when saving.
hold is the same with a reserve step and a heldUntil column; return gives stock back.
The rule’s fields, the three phases and what a writer is told when stock is short are in Rows that post into a ledger.
Discounts, codes and gift cards on an order
Section titled “Discounts, codes and gift cards on an order”Offers & gift cards (offers) keeps offers, discount codes, vouchers and gift cards in its own
tables, with a ledger, value. Its four shapes are not built as new tables: each is added to
tables your app already has. Nothing says builtOn; the rule on your table names the add-on, and
the install checks the rule against it. A till with tickets, ticket_lines and payments takes
all four.
An order that takes discounts and codes (discountable@1). The ticket gets subtotal,
discount, net and the four columns of a reduction given by hand; each line a discount; and a
new table keeps the codes typed on a ticket. The ticket’s table carries the price rule and the
posting that counts what was used:
{ "adjust": { "by": { "addOn": "offers" }, "lines": [{ "table": "ticket_lines", "via": "ticket_id", "price": "line_total", "discount": "discount", "what": [{ "column": "item_id", "as": "item" }], "excludes": { "column": "gift_card_id", "set": true }, "paidBy": { "column": "voucher_id" } }], "order": { "discount": "discount", "staff": { "kind": "discount_kind", "value": "discount_value", "reason": "discount_reason", "by": "discount_by" } }, "codes": { "table": "ticket_codes", "via": "ticket_id", "typed": "typed", "code": "code_id", "voucher": "voucher_id", "removed": "removed_at" }, "uses": "uses" }, "postings": [ { "id": "uses", "into": { "addOn": "offers", "ledger": "value", "action": "redeem" }, "post": { "on": { "to": ["paid"] } }, "reverse": { "on": { "to": ["void"], "from": ["paid"] } }, "map": { "reason": "discount_reason" } } ]}priceis the line’s own total (its price times its quantity), a column your table has.whatnames your own link to the thing sold; an offer for one item, category or tag reads it.excludesandpaidByare for a till that also sells cards and vouchers on the same lines: a line that loads a gift card takes no reduction, and a line that sells a voucher takes none and counts for no minimum. Leave them out where the lines sell neither.- Adminium writes every
discount,discount_by, and the two links of a typed code. A page lets staff type a code by adding a row toticket_codes.
A line that sells or tops up a gift card (card-sale@1) and one that sells a voucher
(voucher-sale@1) are a posting each on the lines, read through the ticket:
"postings": [ { "id": "card-load", "into": { "addOn": "offers", "ledger": "value", "action": "issue" }, "via": "ticket_id", "post": { "on": { "to": ["paid"] } }, "reverse": { "on": { "to": ["void"], "from": ["paid"] } }, "map": { "card": "gift_card_id", "amount": "load_amount" } }]A payment a gift card makes (card-payment@1) is a posting on the payments: the card is found
by the code typed, pays no more than is due and no more than it holds, and gets it back when the
payment is voided.
"postings": [ { "id": "card", "into": { "addOn": "offers", "ledger": "value", "action": "spend" }, "via": "ticket_id", "post": { "on": { "create": true } }, "reverse": { "on": { "column": "voided_at", "set": true, "own": true } }, "map": { "card": "card_id", "due": { "parent": "due" }, "amount": "amount", "balance_after": "card_balance_after" } }]due is the ticket’s column that says what is still to pay. A payments table that also holds cash
names which rows are a card’s with only.
Check the fit. Every column each shape needs, and what Adminium writes into it, is in
The Offers shapes, with a check an app runs in its own tests
(shapeFit). What the price rule does on a save, a quote and a return is in
Discounts and codes.
In Adminium Designer, build_on_shape adds each shape to the tables you name for its parts.
An add-on with tables of its own
Section titled “An add-on with tables of its own”Some add-ons keep their own data rather than a shape for yours: a stock list, gift cards. An app that names such an add-on can ship sample rows for its tables and lets it serve public entries through the app’s key. What such an add-on declares, and how it is installed, is in Add-ons that keep tables of their own.
Your app’s roles on an add-on’s tables
Section titled “Your app’s roles on an add-on’s tables”An add-on’s own roles grant its tables. Your app’s roles may too, with
tables, so a waiter reads stock and a
housekeeper writes a transfer without holding a second role:
{ "key": "housekeeper", "name": { "en-US": "Housekeeper" }, "permissions": ["table:@rooms:read"], "tables": [{ "addOn": "inventory", "table": "transfers", "actions": ["read", "create"], "limit": { "creatable": ["from_place_id", "to_place_id", "note"] } }] }- The grant is made when the add-on is connected to the app and taken back when it is disconnected. The install check lists it.
- It gives
read,createandupdateonly: no delete, no export, no import, and no limit to some rows. limitnarrows the columns for this role. A person who also holds a role with a plain read of the same table reads every column: grants add up.
Printing from an add-on’s page
Section titled “Printing from an add-on’s page”An add-on’s own page prints its documents through the data kit. Your app’s screens print a document of your own tables as in step 6.
Answering the price question
Section titled “Answering the price question”An add-on that keeps offers exports adjust(input) from the server file it names under
provides (contract price-adjust, version 1). It is handed the order’s lines, the codes typed on
it (each already looked up in your tables), who is buying when that was proved, what staff took off
by hand with the most its giver may give, the rows of your offers reads and your settings row —
and answers one reduction per line, what was applied, the codes it refuses with a reason, and, where
the order posts, the uses to record. From Adminium 0.3.19.
- Pure and synchronous. It reads nothing and keeps no clock:
now,today,weekday,timeandzoneare inputs, on the venue’s clock. The same input gives the same answer. A promise, a throw or more than a moment’s work refuses the save. - Nothing it says is taken on trust. Every line answered once and no other, no reduction above its line, the order’s reduction the sum of the lines’, what was applied adding up line by line, every offer, code and voucher named being a row that was read for this call, a reduction by hand within what was asked and within its giver’s limit. One miss and the save is refused; nothing was written yet.
- Modes.
saveanddryprice an order;tryis staff’s preview and may carry adraftoffer and ask you toexplainevery offer;refundprices the lines kept (kept: falsefor a returned line) under the offers the order had. - Adminium writes. You never write the reductions, the applied rows or the uses: Adminium does, in the save’s own transaction, from your answer.
The code runs with the clock, timers, the network and dynamic code taken away. That is hardening against mistakes, not a sandbox: an add-on’s server file is code its installer chose to trust.