Zum Inhalt springen

Building on an add-on

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

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.

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.

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_seq is the next number of an unbroken series, taken inside the write that creates the invoice, starting at the add-on’s number_start_invoice. number is 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 amount is a formula over its quantity, rate and discount; the invoice’s subtotal adds the lines up; tax and total are formulas over it. The arithmetic is exact, and the same on every database. See Formulas.
  • Balance. paid adds up the payments that are not voided and keeps balance beside it; a payment that would take the balance below zero is refused. See Totals and balances.

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.

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" }
}

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" }.

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.

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.

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.

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.

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:

Terminal window
tar -xzOf <folder>/invoices-<version>.tgz package/manifest.json > invoices.manifest.json

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

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:

  1. 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 an int column. No foreign key is made, so the app installs with or without the add-on.
  2. A rule. postings on 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.
  3. A grant. tables on 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.

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.

manifest/tables/visit_supplies.json
{
"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" }
}
]
}
manifest/add-ons.json
{ "requires": [{ "key": "inventory", "range": ">=1.0.8", "reason": { "en-US": "Inventory keeps the stock a visit uses." } }] }
manifest/roles.json (the role's part)
"tables": [{ "addOn": "inventory", "table": "items", "actions": ["read"], "limit": { "readable": ["name", "sku"] } }]
  • via makes the lines follow the visit: status is the visit’s column, and when the visit becomes seen every line is handed over in one call.
  • use-item takes item and quantity. It also takes place, batch and a few more, all optional: with no place, stock comes from the default place in Inventory’s settings.
  • The app’s minAdminiumVersion is 0.3.18 or 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:

manifest/tables/tickets.json (the part that is new)
{
"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" } }
]
}
  • price is the line’s own total (its price times its quantity), a column your table has.
  • what names your own link to the thing sold; an offer for one item, category or tag reads it.
  • excludes and paidBy are 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 to ticket_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:

manifest/tables/ticket_lines.json (the part that is new)
"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.

manifest/tables/payments.json (the part that is new)
"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.

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, create and update only: no delete, no export, no import, and no limit to some rows.
  • limit narrows 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.

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.

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, time and zone are 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. save and dry price an order; try is staff’s preview and may carry a draft offer and ask you to explain every offer; refund prices the lines kept (kept: false for 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.