Skip to main content

apiplant_core/
defaults.rs

1//! Built-in resources.
2//!
3//! `organization`, `membership`, `user`, `api_key` and `oauth_connection` exist
4//! in every app. Together they make apps multitenant out of the box: users join
5//! organisations through memberships (which also carry their **role within that
6//! organisation**), and every other resource is isolated per organisation by
7//! default.
8//!
9//! `invitation` and `auth_token` are the two tables behind the flows that reach
10//! somebody through their mailbox — being invited to an organisation,
11//! confirming an address, resetting a password. They exist in every app, and in
12//! one with no `[email]` provider they simply stay empty: the endpoints that
13//! write to them are not mounted at all.
14//!
15//! Two sets are *conditional*, and for the same reason: they are machinery for
16//! a feature most apps do not turn on, and a table nobody ever writes to is
17//! noise in a dashboard. The `billing_*` resources arrive with a `[payments]`
18//! provider ([`billing_builtins`]), and `oauth_state` with an `[oauth]` one
19//! ([`oauth_builtins`]).
20//!
21//! Drop a `models/<name>.toml` with the same `name` to replace a built-in and
22//! add fields or tweak permissions while keeping the machinery working.
23
24use crate::schema::Resource;
25
26/// The `organization` — the tenant. `global` because its rows *are* the
27/// organisations; membership (not an `organization_id`) decides who sees them.
28pub const ORGANIZATION_TOML: &str = r#"
29[resource]
30name = "organization"
31scope = "global"
32timestamps = true
33
34[permissions]
35list   = "member"          # organisations you belong to
36read   = "member"
37create = "authenticated"   # anyone may start one (and becomes its admin)
38update = "role:admin"      # an admin *of that organisation*
39delete = "role:admin"
40
41[fields.name]
42type = "string"
43required = true
44
45[fields.slug]
46type = "string"
47unique = true
48
49# A logo, as a URL a browser can fetch. Nothing sets it — an organisation is not
50# handed to us by an identity provider the way a person is — so it is here for
51# an app to fill and for every interface to read: the dashboard's workspace
52# switcher shows it in place of the initials it would otherwise draw.
53[fields.avatar_url]
54type = "string"
55max_length = 1024
56"#;
57
58/// A user's membership in an organisation, carrying their role there. The
59/// join table behind the N:N between `user` and `organization`.
60pub const MEMBERSHIP_TOML: &str = r#"
61[resource]
62name = "membership"
63scope = "organization"
64timestamps = true
65
66[permissions]
67list   = "member"          # members can see who else is in the org
68read   = "member"
69create = "role:admin"      # admins add members
70update = "role:admin"
71delete = "role:admin"
72
73# Built-in function: lets `create` name the person by `email` instead of by
74# `user_id`, and refuses a duplicate membership. The lookup has to happen here
75# because `user` is only readable by people you already share an org with.
76[hooks]
77before_create = "apiplant_organization_join"
78
79[fields.user_id]
80type = "reference"
81references = "user"
82required = true
83on_delete = "cascade"      # a deleted account takes its memberships with it
84
85[fields.organization_id]
86type = "reference"
87references = "organization"
88required = true
89
90[fields.role]
91type = "string"            # the member's *primary* role here, e.g. "admin".
92                           # Further roles are `membership_role` rows; a
93                           # `role:` permission is checked against all of them.
94"#;
95
96/// A single role held by a membership.
97///
98/// Roles are a set, not a slot: someone can be a `billing` *and* a `support`
99/// person without either displacing the other, which one column cannot express.
100/// [`MEMBERSHIP_TOML`]'s `role` stays as the member's **primary** role — it is
101/// what existing apps and hook contexts read — and these rows are the rest.
102/// Together they are the roles a `role:` permission is checked against.
103///
104/// `admin` is special: it satisfies every role check in its organisation
105/// without needing a row per role, so granting someone `admin` grants them
106/// everything the app defines.
107pub const MEMBERSHIP_ROLE_TOML: &str = r#"
108[resource]
109name = "membership_role"
110scope = "organization"
111timestamps = true
112
113[permissions]
114list   = "member"          # members can see who holds what
115read   = "member"
116create = "role:admin"      # admins grant roles
117update = "role:admin"
118delete = "role:admin"
119
120[fields.membership_id]
121type = "reference"
122references = "membership"
123required = true
124on_delete = "cascade"      # a removed member takes their roles with them
125
126[fields.organization_id]
127type = "reference"
128references = "organization"
129required = true
130
131[fields.role]
132type = "string"
133required = true
134"#;
135
136/// The default `user`: global (users are shared across organisations) with
137/// email + password auth. Extend via `models/users.toml`.
138pub const USER_TOML: &str = r#"
139[resource]
140name = "user"
141scope = "global"
142timestamps = true
143
144[permissions]
145list   = "member"          # people you share an organisation with
146read   = "member"
147create = "public"          # registration
148update = "owner"
149delete = "private"
150
151[auth]
152identity_field = "email"
153password_field = "password_hash"
154oauth_providers = []
155
156[fields.email]
157type = "string"
158required = true
159unique = true
160max_length = 320
161
162[fields.password_hash]
163type = "string"
164hidden = true
165
166# When the address was confirmed. Null means unconfirmed — which only *stops*
167# anyone when `[auth] require_email_verification` is on, so an app with no
168# mailer carries the column and never looks at it.
169[fields.email_verified_at]
170type = "timestamp"
171
172[fields.email_verified_at.admin]
173visible = false
174
175# What to call somebody, and what to show beside their name.
176#
177# Both are ordinary nullable columns nothing requires — an app that has no use
178# for either can leave them empty or drop them by replacing this model. They are
179# here because they are what almost every app wants and what every identity
180# provider hands over: a sign-in through [`[oauth]`](crate::config::OAuthConfig)
181# fills them in, so an account that arrives that way arrives with a name and a
182# picture rather than an email address and a blank.
183[fields.display_name]
184type = "string"
185
186[fields.avatar_url]
187type = "string"
188max_length = 1024
189
190# True when the address above is one apiplant invented rather than one somebody
191# gave it.
192#
193# A sign-in through a provider that releases no address — X, today — still has
194# to put *something* in the identity column, which is required and unique. It
195# writes `<provider>_<id>@oauth.invalid`, at a TLD RFC 2606 reserves so that it
196# can never resolve.
197#
198# This flag is here, in every app, because the framework is the one inventing
199# that value: fabricating an address and not recording that it was fabricated
200# leaves an app to find out by watching mail bounce. With it, "we have an
201# address for this person" stops being the same question as "there is a string
202# in the email column" — which is the question a welcome email, a newsletter
203# and a CSV export all actually mean to ask.
204[fields.email_placeholder]
205type = "boolean"
206default = false
207
208[fields.email_placeholder.admin]
209visible = false
210"#;
211
212/// An invitation to join an organisation, addressed to someone who may not have
213/// an account yet.
214///
215/// This is the table behind `POST <base>/auth/invitations`. The emailed link
216/// carries a token whose **hash** is what lives here, for the same reason an
217/// API key's does: a leaked database should not be a pile of working links.
218///
219/// It is org-scoped, so an admin only ever sees the invitations to their own
220/// organisation, and `read`/`create` are `role:admin` — the endpoints that a
221/// person *without* an account uses (previewing a link, accepting it) are not
222/// CRUD on this resource at all, and reach the row through the token they were
223/// sent rather than through the API's permissions.
224pub const INVITATION_TOML: &str = r#"
225[resource]
226name = "invitation"
227scope = "organization"
228timestamps = true
229
230[permissions]
231list   = "role:admin"      # admins see who is still pending
232read   = "role:admin"
233create = "private"         # issued by POST /auth/invitations, never by hand:
234                           # a row written directly has no token to send
235update = "private"
236delete = "role:admin"      # revoking is deleting the row
237
238[fields.email]
239type = "string"
240required = true
241max_length = 320
242
243[fields.role]
244type = "string"            # the role they will hold once they accept
245
246[fields.token_hash]
247type = "string"
248required = true
249unique = true
250hidden = true
251
252[fields.invited_by]
253type = "reference"
254references = "user"
255on_delete = "set_null"
256
257[fields.expires_at]
258type = "timestamp"
259required = true
260
261# Set when the invitation is used. A row with this filled in is history, kept
262# so "who let them in, and when" survives the membership being edited later.
263[fields.accepted_at]
264type = "timestamp"
265"#;
266
267/// A single-use token sent to an address to prove someone reads it.
268///
269/// Both address confirmation and password reset are the same shape — mint a
270/// secret, mail it, accept it once, before it expires — so they are one table
271/// distinguished by `kind` rather than two that would drift apart.
272///
273/// Entirely `private`: every row is created and spent by the framework's own
274/// endpoints, and there is nothing here anybody should read over the API. The
275/// plaintext exists only in the message that was sent.
276pub const AUTH_TOKEN_TOML: &str = r#"
277[resource]
278name = "auth_token"
279scope = "global"
280timestamps = true
281
282[admin]
283label = "Auth token"
284plural = "Auth tokens"
285visible = false
286
287[permissions]
288list   = "private"
289read   = "private"
290create = "private"
291update = "private"
292delete = "private"
293
294[fields.user_id]
295type = "reference"
296references = "user"
297required = true
298on_delete = "cascade"      # a deleted account takes its live links with it
299
300[fields.kind]
301type = "string"
302required = true            # "email_verification" or "password_reset"
303
304[fields.token_hash]
305type = "string"
306required = true
307unique = true
308hidden = true
309
310[fields.expires_at]
311type = "timestamp"
312required = true
313
314# Set the moment the token is spent, so a link in a mailbox works exactly once.
315[fields.used_at]
316type = "timestamp"
317"#;
318
319/// Default `api_key` resource (global). A valid key authenticates as its owner.
320pub const API_KEY_TOML: &str = r#"
321[resource]
322name = "api_key"
323scope = "global"
324timestamps = true
325
326# "Api key" is what titleising the name produces, and it is not how anybody
327# writes it.
328[admin]
329label = "API key"
330plural = "API keys"
331
332[permissions]
333list   = "owner"
334read   = "owner"
335create = "authenticated"
336update = "private"
337delete = "owner"
338
339[fields.name]
340type = "string"
341
342[fields.token_hash]
343type = "string"
344required = true
345unique = true
346hidden = true
347
348[fields.owner_id]
349type = "reference"
350references = "user"
351required = true
352"#;
353
354/// Default `oauth_connection` resource (global) linking a user to a provider.
355///
356/// One row per (provider, account at that provider). Somebody may hold four of
357/// them, and signing in through any one reaches the same `user` — which is the
358/// whole point of the table: an account is not its GitHub account, it *has* one.
359///
360/// It is a live, ordinary resource: `GET <base>/oauth_connection` is how a
361/// client draws somebody's linked accounts, and it is `owner`-scoped, so that
362/// question needs no filter and cannot answer anybody else's.
363///
364/// The profile columns are refreshed on every sign-in, because people change
365/// their name and their picture. There is deliberately no access token and no
366/// refresh token here: `<base>/auth/oauth` authenticates people and never acts
367/// on their behalf afterwards, so the token is used once — to read the profile —
368/// and dropped. An app that does need to keep calling the provider adds those
369/// columns itself and encrypts them at rest; `hidden` keeps a value out of API
370/// responses, which is not the same as keeping it out of a database dump.
371pub const OAUTH_TOML: &str = r#"
372[resource]
373name = "oauth_connection"
374scope = "global"
375timestamps = true
376
377# Readable by whoever it belongs to — `GET <base>/oauth_connection` is "my
378# linked accounts", with no filter in the request saying so — and written by
379# nobody but the framework.
380#
381# `delete` is **private**, which is the one that looks wrong and is not:
382# removing a connection has an invariant that a row deletion cannot see. An
383# account with no password and no second provider becomes permanently
384# unreachable the moment its last connection goes, so unlinking is
385# `DELETE <base>/auth/oauth/{provider}`, which checks what else is left and
386# refuses the last one. Leaving `delete = "owner"` here would put a door beside
387# that check with nothing behind it but the same table.
388[permissions]
389list   = "owner"
390read   = "owner"
391create = "private"
392update = "private"
393delete = "private"
394
395[fields.provider]
396type = "string"
397required = true
398
399# The provider's own immutable id for this person — GitHub's numeric id,
400# Google's `sub`, X's user id. Never a username: GitHub and X both let people
401# change theirs and let the freed name be taken by somebody else, so an account
402# keyed on one would hand the new owner the old owner's account.
403[fields.provider_user_id]
404type = "string"
405required = true
406
407# `provider:provider_user_id`, so the pair can carry a UNIQUE constraint — a
408# single column is what `unique` can express. It is what makes two simultaneous
409# first-time sign-ins from one GitHub account produce one user instead of two:
410# the loser's insert conflicts, and it reads back the winner's row rather than
411# creating a second account.
412[fields.provider_key]
413type = "string"
414unique = true
415max_length = 320
416
417[fields.owner_id]
418type = "reference"
419references = "user"
420required = true
421on_delete = "cascade"
422
423# What the provider last said about them. `email_verified` is the field the
424# account-matching rule hangs on, so it records what the *provider* claimed
425# rather than what this app would like to be true.
426[fields.email]
427type = "string"
428max_length = 320
429
430[fields.email_verified]
431type = "boolean"
432default = false
433
434[fields.display_name]
435type = "string"
436
437[fields.avatar_url]
438type = "string"
439max_length = 1024
440
441[fields.last_login_at]
442type = "timestamp"
443"#;
444
445/// The half-finished handshake: `oauth_state`.
446///
447/// Present only in an app with an `[oauth]` provider, because it is machinery
448/// and not domain: two requests, minutes apart, with a consent screen between
449/// them, and everything the second one must not take on trust from the browser
450/// waiting somewhere the browser cannot reach.
451///
452/// A cache with a TTL would do the same job in less space. A table is used
453/// because it survives a restart — a redeploy in the ninety seconds somebody
454/// spends reading a consent screen should not fail their sign-in — and because
455/// it needs no Redis to exist.
456pub const OAUTH_STATE_TOML: &str = r#"
457[resource]
458name = "oauth_state"
459scope = "global"
460timestamps = true
461
462# Machinery, like `auth_token`: rows appear for ninety seconds while somebody
463# reads a consent screen and are never worth looking at afterwards, so the
464# dashboard does not offer a screen for them.
465[admin]
466label = "OAuth sign-in"
467plural = "OAuth sign-ins"
468visible = false
469
470# Nothing may reach this table over the API — not even the person whose sign-in
471# it is. Every column on it is either a secret or a decision that stops being
472# safe the moment a client can change it, and its only reader is the callback
473# endpoint, which goes to the table directly.
474[permissions]
475list   = "private"
476read   = "private"
477create = "private"
478update = "private"
479delete = "private"
480
481[fields.provider]
482type = "string"
483required = true
484
485# SHA-256 of the `state` parameter, not the parameter. The value itself travels
486# in a URL, through browser history and the provider's logs; only its hash is
487# kept, so this table leaking does not let anybody finish a flow in progress.
488# SHA-256 rather than argon2 for the same reason an API key's hash is: the
489# callback looks the row up *by* this column.
490[fields.state_hash]
491type = "string"
492required = true
493unique = true
494hidden = true
495
496# The PKCE verifier, where the provider supports PKCE. Only its SHA-256 travels
497# with the authorize redirect, so intercepting that redirect — or the code that
498# comes back on it — is not enough to redeem anything.
499[fields.verifier]
500type = "string"
501hidden = true
502
503# Repeated verbatim in the token request, because the provider compares the two
504# and refuses on any difference. Recorded rather than recomputed so that a
505# config change mid-flow cannot strand a sign-in.
506[fields.redirect_uri]
507type = "string"
508max_length = 1024
509
510# Set when the flow was started by somebody already signed in: this is "connect
511# my GitHub", not "sign me in". It is decided here, while holding that account's
512# session, and never read from the callback — which is the difference between
513# linking an account you can prove is yours and linking any account whose id you
514# can name.
515[fields.link_user_id]
516type = "reference"
517references = "user"
518on_delete = "cascade"
519
520# Where to send the browser afterwards. Only ever a path on this site; see the
521# `return_to` handling in the oauth routes.
522[fields.return_to]
523type = "string"
524max_length = 1024
525
526# How the caller asked to be given the token, overriding `[oauth]
527# token_delivery` for this one flow. Empty means "however the app is
528# configured".
529#
530# It is recorded rather than read from the callback for the ordinary reason:
531# the callback is a request the provider makes, and nothing about it is the
532# app's word. A first-party client — the admin dashboard, say — knows how it
533# wants to receive a token, and this is where it says so.
534[fields.token_delivery]
535type = "string"
536max_length = 16
537
538[fields.expires_at]
539type = "timestamp"
540required = true
541
542# Stamped by the callback. A code may be redeemed once, and so may the state
543# that authorised it: a second callback carrying the same one is a double-click
544# or an attack, and is refused either way.
545[fields.used_at]
546type = "timestamp"
547"#;
548
549// --- billing ------------------------------------------------------------
550//
551// The five `billing_*` resources exist only in an app whose `[payments]`
552// section names a provider — see [`billing_builtins`]. They are prefixed for
553// the same reason the built-in functions are: `product` and `price` are words
554// an ordinary app wants for its own domain, and a framework that took them
555// would be taking them from a shop that sells things.
556//
557// The split between them is the split Stripe already makes, and copying it is
558// deliberate: a **product** is the thing you sell, a **price** is one way to
559// pay for it (monthly, yearly, one-off), and a product with three prices is
560// three ways to buy one thing rather than three things. Because these are
561// ordinary resources, the catalogue is CRUD — a `role:admin` can add a plan
562// from the dashboard, and the hooks below mirror it into Stripe — while
563// `billing_customer`, `billing_subscription` and `billing_payment` are
564// `private`: Stripe decides what is paid for and the webhook writes it down.
565
566/// A thing the app sells. Global: the catalogue is the same for every tenant.
567///
568/// Writable by an admin, and every write is mirrored into Stripe by the
569/// [`apiplant_stripe_product`] built-in, so a plan added in the dashboard
570/// exists in Stripe before the row is committed. `stripe_product_id` is what
571/// that hook fills in; nothing else should.
572///
573/// [`apiplant_stripe_product`]: https://docs.rs/apiplant-server
574pub const BILLING_PRODUCT_TOML: &str = r#"
575[resource]
576name = "billing_product"
577scope = "global"
578timestamps = true
579
580[admin]
581label = "Product"
582plural = "Products"
583
584[permissions]
585list   = "public"          # a pricing page is read by people with no account
586read   = "public"
587create = "role:admin"
588update = "role:admin"
589delete = "role:admin"
590
591# Mirror the catalogue into Stripe. Creating the row creates the product;
592# renaming it renames it; archiving it (active = false) archives it there too.
593[hooks]
594before_create = "apiplant_stripe_product"
595before_update = "apiplant_stripe_product"
596
597[fields.name]
598type = "string"
599required = true
600
601[fields.description]
602type = "text"
603
604# Off the price list without deleting the history that points at it. Stripe
605# calls this "archived", and a product with live subscriptions cannot be
606# deleted in either system — only stopped from being bought again.
607[fields.active]
608type = "boolean"
609default = true
610
611# Free-form facts the app checks when deciding what a plan may do: seat
612# limits, feature flags, an internal tier name. Copied to Stripe as metadata
613# so an operator reading either system sees the same thing.
614[fields.features]
615type = "json"
616
617[fields.stripe_product_id]
618type = "string"
619unique = true
620
621[fields.stripe_product_id.admin]
622readonly = true
623help = "Filled in by Stripe when the product is first saved."
624"#;
625
626/// One way to pay for a [product](BILLING_PRODUCT_TOML): an amount, a
627/// currency, and either a billing interval (a subscription) or none (a one-off
628/// payment).
629///
630/// Amounts are in the currency's **smallest unit** — 1000 is €10.00 — because
631/// that is the only representation that is exact, and it is what Stripe,
632/// every card network and every accountant's ledger already use. A float here
633/// would be a rounding error waiting for a big enough invoice.
634pub const BILLING_PRICE_TOML: &str = r#"
635[resource]
636name = "billing_price"
637scope = "global"
638timestamps = true
639
640[admin]
641label = "Price"
642plural = "Prices"
643
644[permissions]
645list   = "public"
646read   = "public"
647create = "role:admin"
648update = "role:admin"    # only `active` and presentation: see the hook
649delete = "role:admin"
650
651[hooks]
652before_create = "apiplant_stripe_price"
653before_update = "apiplant_stripe_price"
654
655[fields.product_id]
656type = "reference"
657references = "billing_product"
658required = true
659on_delete = "cascade"      # a deleted product takes its price list with it
660
661[fields.nickname]
662type = "string"            # "Monthly", "Yearly (2 months free)"
663
664# The charge, in the currency's smallest unit: 1000 = €10.00 = $10.00.
665[fields.unit_amount]
666type = "big_int"
667required = true
668
669[fields.unit_amount.admin]
670help = "In the smallest unit of the currency — 1000 is 10.00."
671
672[fields.currency]
673type = "string"
674max_length = 3             # ISO 4217; empty takes [payments] currency
675
676# How often it recurs: "month", "year", "week", "day", or empty for a price
677# that is charged once. This is what decides whether buying it starts a
678# subscription or takes a single payment.
679[fields.interval]
680type = "string"
681
682[fields.interval.admin]
683options = ["|One-off", "day|Daily", "week|Weekly", "month|Monthly", "year|Yearly"]
684
685# Charge every N intervals — 3 with interval = "month" is quarterly.
686[fields.interval_count]
687type = "integer"
688default = 1
689
690# Days before the first charge. 0 (the default) starts billing immediately.
691[fields.trial_days]
692type = "integer"
693default = 0
694
695# Whether the amount already includes tax. "exclusive" (the default) means tax
696# is added on top, which is what [payments] automatic_tax computes; "inclusive"
697# means the amount is the total and the tax is worked out from within it.
698[fields.tax_behavior]
699type = "string"
700default = "exclusive"
701
702[fields.tax_behavior.admin]
703options = ["exclusive|Tax added on top", "inclusive|Tax included in the amount"]
704
705[fields.active]
706type = "boolean"
707default = true
708
709# Stripe prices are immutable once created: changing an amount there means
710# creating a new price and archiving the old one, which is exactly what the
711# hook does. The id therefore points at whichever price object is current.
712[fields.stripe_price_id]
713type = "string"
714unique = true
715
716[fields.stripe_price_id.admin]
717readonly = true
718help = "Filled in by Stripe. Changing the amount creates a new price and archives the old one."
719"#;
720
721/// The organisation as Stripe knows it: one row per tenant, holding the
722/// customer id every charge and subscription hangs off.
723///
724/// Org-scoped and `private`. It is written by the checkout endpoint and the
725/// webhook, never by hand — a second customer for one organisation would
726/// split its payment methods, its invoices and its tax status across two
727/// records that neither system would ever reconcile.
728pub const BILLING_CUSTOMER_TOML: &str = r#"
729[resource]
730name = "billing_customer"
731scope = "organization"
732timestamps = true
733
734[admin]
735label = "Billing customer"
736plural = "Billing customers"
737
738[permissions]
739list   = "role:admin"      # billing is the admins' business
740read   = "role:admin"
741create = "private"
742update = "private"
743delete = "private"
744
745[fields.stripe_customer_id]
746type = "string"
747required = true
748unique = true
749
750# Where invoices and receipts go. Defaults to the address of whoever first
751# started a checkout, and is changed from the Stripe portal.
752[fields.email]
753type = "string"
754max_length = 320
755
756[fields.name]
757type = "string"
758
759# The buyer's VAT/GST number, once they have given one. Kept because it is
760# what turns a taxed sale into a reverse-charge one, and support gets asked
761# about it more than anything else on this table.
762[fields.tax_id]
763type = "string"
764
765# Country Stripe places the customer in, for tax. Two-letter ISO 3166-1.
766[fields.tax_country]
767type = "string"
768max_length = 2
769
770# Everything else Stripe holds about the customer, as last delivered. Kept so
771# an operator can answer a billing question without opening two dashboards.
772[fields.details]
773type = "json"
774
775[fields.details.admin]
776visible = false
777"#;
778
779/// A live (or lapsed) subscription: one organisation paying for one
780/// [price](BILLING_PRICE_TOML) on a schedule.
781///
782/// `private` for writes and readable by any member, which is what makes it
783/// usable as an entitlement check — a function asking "is this org on a paid
784/// plan" reads a row through the same permissions as everything else. Stripe
785/// is the source of truth and the webhook is what writes here: a row edited by
786/// hand would say the customer is paying when they are not.
787pub const BILLING_SUBSCRIPTION_TOML: &str = r#"
788[resource]
789name = "billing_subscription"
790scope = "organization"
791timestamps = true
792
793[admin]
794label = "Subscription"
795plural = "Subscriptions"
796
797[permissions]
798list   = "member"          # members can see what plan they are on
799read   = "member"
800create = "private"         # started by POST /billing/checkout
801update = "private"         # written by the Stripe webhook
802delete = "private"
803
804[fields.price_id]
805type = "reference"
806references = "billing_price"
807on_delete = "set_null"     # an archived price outlives its row
808
809[fields.customer_id]
810type = "reference"
811references = "billing_customer"
812on_delete = "cascade"
813
814# Stripe's own words: "trialing", "active", "past_due", "canceled",
815# "incomplete", "incomplete_expired", "unpaid", "paused". Anything checking
816# for a paying customer wants "active" or "trialing" — see the `payments`
817# guide for why "past_due" is a judgement call and not a status.
818[fields.status]
819type = "string"
820required = true
821
822[fields.quantity]
823type = "integer"
824default = 1
825
826[fields.current_period_end]
827type = "timestamp"
828
829# Set when the customer has asked to stop but has already paid through the end
830# of the period: still entitled, not renewing.
831[fields.cancel_at_period_end]
832type = "boolean"
833default = false
834
835[fields.trial_ends_at]
836type = "timestamp"
837
838[fields.canceled_at]
839type = "timestamp"
840
841[fields.stripe_subscription_id]
842type = "string"
843required = true
844unique = true
845
846[fields.stripe_subscription_id.admin]
847readonly = true
848"#;
849
850/// One payment that happened: a one-off purchase, or an invoice a
851/// subscription generated.
852///
853/// A ledger, not a state machine — a row per attempt Stripe told us about,
854/// kept whether it succeeded or not, because "the card was declined on the
855/// 3rd" is the answer to most billing questions.
856pub const BILLING_PAYMENT_TOML: &str = r#"
857[resource]
858name = "billing_payment"
859scope = "organization"
860timestamps = true
861
862[admin]
863label = "Payment"
864plural = "Payments"
865
866[permissions]
867list   = "role:admin"
868read   = "role:admin"
869create = "private"
870update = "private"
871delete = "private"
872
873[fields.customer_id]
874type = "reference"
875references = "billing_customer"
876on_delete = "cascade"
877
878[fields.price_id]
879type = "reference"
880references = "billing_price"
881on_delete = "set_null"
882
883[fields.subscription_id]
884type = "reference"
885references = "billing_subscription"
886on_delete = "set_null"     # null for a one-off
887
888# What was actually taken, in the smallest unit, and what of it was tax.
889[fields.amount]
890type = "big_int"
891required = true
892
893[fields.tax_amount]
894type = "big_int"
895default = 0
896
897[fields.currency]
898type = "string"
899max_length = 3
900
901# "succeeded", "pending", "failed", "refunded".
902[fields.status]
903type = "string"
904required = true
905
906[fields.description]
907type = "string"
908
909# The buyer's own receipt, hosted by Stripe. Worth storing: it is the link
910# support is asked for, and it outlives the session that produced it.
911[fields.receipt_url]
912type = "string"
913max_length = 2048
914
915[fields.paid_at]
916type = "timestamp"
917
918[fields.stripe_payment_intent_id]
919type = "string"
920unique = true
921
922[fields.stripe_invoice_id]
923type = "string"
924"#;
925
926/// Every webhook Stripe has delivered, by its event id.
927///
928/// This table is the idempotency: Stripe retries a delivery until it is
929/// acknowledged and may deliver the same event twice regardless, so the
930/// handler inserts the id first and does the work only if the insert was new.
931/// Without it a retried `invoice.paid` is a second row in the ledger and a
932/// customer who appears to have paid twice.
933///
934/// Entirely `private`, and hidden from the dashboard: it is a log with one
935/// reader, which is the handler itself.
936pub const BILLING_EVENT_TOML: &str = r#"
937[resource]
938name = "billing_event"
939scope = "global"
940timestamps = true
941
942[admin]
943label = "Billing event"
944plural = "Billing events"
945visible = false
946
947[permissions]
948list   = "private"
949read   = "private"
950create = "private"
951update = "private"
952delete = "private"
953
954[fields.stripe_event_id]
955type = "string"
956required = true
957unique = true
958
959[fields.kind]
960type = "string"
961required = true            # "checkout.session.completed", "invoice.paid", …
962
963# When the work finished. A row with this null is an event that arrived and
964# then failed to process — Stripe will retry it, and this is where to look
965# when it stops retrying.
966[fields.processed_at]
967type = "timestamp"
968
969[fields.error]
970type = "text"
971
972[fields.payload]
973type = "json"
974hidden = true
975"#;
976
977/// The name → embedded-TOML table of built-ins, in dependency order so foreign
978/// keys resolve (organization and user before membership/api_key/oauth).
979pub fn builtins() -> Vec<(&'static str, &'static str)> {
980    vec![
981        ("organization", ORGANIZATION_TOML),
982        ("user", USER_TOML),
983        ("membership", MEMBERSHIP_TOML),
984        ("membership_role", MEMBERSHIP_ROLE_TOML),
985        ("api_key", API_KEY_TOML),
986        ("oauth_connection", OAUTH_TOML),
987        ("invitation", INVITATION_TOML),
988        ("auth_token", AUTH_TOKEN_TOML),
989    ]
990}
991
992/// The billing resources, in dependency order (product before price, customer
993/// before subscription before payment).
994///
995/// Unlike [`builtins`] these are conditional: an app gets them when its
996/// `[payments]` section names a provider, and not otherwise. Five tables and
997/// five sets of endpoints is a lot to hand an app that never takes money, and
998/// unlike `invitation` — which is two columns behind a feature most apps do
999/// eventually turn on — a catalogue nobody sells from is just noise in the
1000/// dashboard.
1001///
1002/// Migrations are additive, so switching payments *off* leaves the tables
1003/// where they are: the data outlives the config, which is the right way round
1004/// for anything that recorded money changing hands.
1005pub fn billing_builtins() -> Vec<(&'static str, &'static str)> {
1006    vec![
1007        ("billing_product", BILLING_PRODUCT_TOML),
1008        ("billing_price", BILLING_PRICE_TOML),
1009        ("billing_customer", BILLING_CUSTOMER_TOML),
1010        ("billing_subscription", BILLING_SUBSCRIPTION_TOML),
1011        ("billing_payment", BILLING_PAYMENT_TOML),
1012        ("billing_event", BILLING_EVENT_TOML),
1013    ]
1014}
1015
1016/// The one resource an app gets for having an `[oauth]` provider.
1017///
1018/// Conditional for the same reason the billing tables are: `oauth_state` is
1019/// pure machinery, empty except during a sign-in, and an app that signs nobody
1020/// in through a third party has no use for a table in its dashboard that never
1021/// holds a row. `oauth_connection` is *not* here — it is a built-in for every
1022/// app, because "which accounts is this person known by" is a question worth
1023/// having a shape for even before anybody answers it.
1024pub fn oauth_builtins() -> Vec<(&'static str, &'static str)> {
1025    vec![("oauth_state", OAUTH_STATE_TOML)]
1026}
1027
1028/// Parse one built-in by its embedded TOML. Panics on a malformed built-in —
1029/// that would be a bug in this crate, caught by the test below.
1030pub fn parse_builtin(toml_src: &str) -> Resource {
1031    let r: Resource = toml::from_str(toml_src).expect("built-in resource TOML is valid");
1032    r.validate().expect("built-in resource is valid");
1033    r
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039
1040    #[test]
1041    fn all_builtins_parse() {
1042        for (name, src) in builtins()
1043            .into_iter()
1044            .chain(billing_builtins())
1045            .chain(oauth_builtins())
1046        {
1047            let r = parse_builtin(src);
1048            assert_eq!(r.meta.name, name);
1049        }
1050    }
1051
1052    /// The prefix is what keeps a shop's own `product` model out of the
1053    /// framework's way, so it holds for every billing resource, not just the
1054    /// two that would have collided today.
1055    #[test]
1056    fn every_billing_resource_is_namespaced() {
1057        for (name, _) in billing_builtins() {
1058            assert!(name.starts_with("billing_"), "`{name}` is unprefixed");
1059        }
1060    }
1061
1062    /// A `reference` that names a resource nothing declares migrates to a
1063    /// foreign key against a table that isn't there, and the app fails to
1064    /// boot. Every target here is either a billing resource or a core one.
1065    #[test]
1066    fn billing_references_resolve_within_the_app() {
1067        let known: Vec<&str> = builtins()
1068            .into_iter()
1069            .chain(billing_builtins())
1070            .map(|(name, _)| name)
1071            .collect();
1072        for (name, src) in billing_builtins() {
1073            let resource = parse_builtin(src);
1074            for (field, spec) in &resource.fields {
1075                if let Some(target) = &spec.references {
1076                    assert!(
1077                        known.contains(&target.as_str()),
1078                        "{name}.{field} points at an unknown `{target}`"
1079                    );
1080                }
1081            }
1082        }
1083    }
1084}