apiplant_core/
defaults.rs1use crate::schema::Resource;
19
20pub const ORGANIZATION_TOML: &str = r#"
23[resource]
24name = "organization"
25scope = "global"
26timestamps = true
27
28[permissions]
29list = "member" # organisations you belong to
30read = "member"
31create = "authenticated" # anyone may start one (and becomes its admin)
32update = "role:admin" # an admin *of that organisation*
33delete = "role:admin"
34
35[fields.name]
36type = "string"
37required = true
38
39[fields.slug]
40type = "string"
41unique = true
42"#;
43
44pub const MEMBERSHIP_TOML: &str = r#"
47[resource]
48name = "membership"
49scope = "organization"
50timestamps = true
51
52[permissions]
53list = "member" # members can see who else is in the org
54read = "member"
55create = "role:admin" # admins add members
56update = "role:admin"
57delete = "role:admin"
58
59# Built-in function: lets `create` name the person by `email` instead of by
60# `user_id`, and refuses a duplicate membership. The lookup has to happen here
61# because `user` is only readable by people you already share an org with.
62[hooks]
63before_create = "apiplant_organization_join"
64
65[fields.user_id]
66type = "reference"
67references = "user"
68required = true
69on_delete = "cascade" # a deleted account takes its memberships with it
70
71[fields.organization_id]
72type = "reference"
73references = "organization"
74required = true
75
76[fields.role]
77type = "string" # the member's *primary* role here, e.g. "admin".
78 # Further roles are `membership_role` rows; a
79 # `role:` permission is checked against all of them.
80"#;
81
82pub const MEMBERSHIP_ROLE_TOML: &str = r#"
94[resource]
95name = "membership_role"
96scope = "organization"
97timestamps = true
98
99[permissions]
100list = "member" # members can see who holds what
101read = "member"
102create = "role:admin" # admins grant roles
103update = "role:admin"
104delete = "role:admin"
105
106[fields.membership_id]
107type = "reference"
108references = "membership"
109required = true
110on_delete = "cascade" # a removed member takes their roles with them
111
112[fields.organization_id]
113type = "reference"
114references = "organization"
115required = true
116
117[fields.role]
118type = "string"
119required = true
120"#;
121
122pub const USER_TOML: &str = r#"
125[resource]
126name = "user"
127scope = "global"
128timestamps = true
129
130[permissions]
131list = "member" # people you share an organisation with
132read = "member"
133create = "public" # registration
134update = "owner"
135delete = "private"
136
137[auth]
138identity_field = "email"
139password_field = "password_hash"
140oauth_providers = []
141
142[fields.email]
143type = "string"
144required = true
145unique = true
146max_length = 320
147
148[fields.password_hash]
149type = "string"
150hidden = true
151
152# When the address was confirmed. Null means unconfirmed — which only *stops*
153# anyone when `[auth] require_email_verification` is on, so an app with no
154# mailer carries the column and never looks at it.
155[fields.email_verified_at]
156type = "timestamp"
157
158[fields.email_verified_at.admin]
159visible = false
160
161[fields.display_name]
162type = "string"
163"#;
164
165pub const INVITATION_TOML: &str = r#"
178[resource]
179name = "invitation"
180scope = "organization"
181timestamps = true
182
183[permissions]
184list = "role:admin" # admins see who is still pending
185read = "role:admin"
186create = "private" # issued by POST /auth/invitations, never by hand:
187 # a row written directly has no token to send
188update = "private"
189delete = "role:admin" # revoking is deleting the row
190
191[fields.email]
192type = "string"
193required = true
194max_length = 320
195
196[fields.role]
197type = "string" # the role they will hold once they accept
198
199[fields.token_hash]
200type = "string"
201required = true
202unique = true
203hidden = true
204
205[fields.invited_by]
206type = "reference"
207references = "user"
208on_delete = "set_null"
209
210[fields.expires_at]
211type = "timestamp"
212required = true
213
214# Set when the invitation is used. A row with this filled in is history, kept
215# so "who let them in, and when" survives the membership being edited later.
216[fields.accepted_at]
217type = "timestamp"
218"#;
219
220pub const AUTH_TOKEN_TOML: &str = r#"
230[resource]
231name = "auth_token"
232scope = "global"
233timestamps = true
234
235[admin]
236label = "Auth token"
237plural = "Auth tokens"
238visible = false
239
240[permissions]
241list = "private"
242read = "private"
243create = "private"
244update = "private"
245delete = "private"
246
247[fields.user_id]
248type = "reference"
249references = "user"
250required = true
251on_delete = "cascade" # a deleted account takes its live links with it
252
253[fields.kind]
254type = "string"
255required = true # "email_verification" or "password_reset"
256
257[fields.token_hash]
258type = "string"
259required = true
260unique = true
261hidden = true
262
263[fields.expires_at]
264type = "timestamp"
265required = true
266
267# Set the moment the token is spent, so a link in a mailbox works exactly once.
268[fields.used_at]
269type = "timestamp"
270"#;
271
272pub const API_KEY_TOML: &str = r#"
274[resource]
275name = "api_key"
276scope = "global"
277timestamps = true
278
279# "Api key" is what titleising the name produces, and it is not how anybody
280# writes it.
281[admin]
282label = "API key"
283plural = "API keys"
284
285[permissions]
286list = "owner"
287read = "owner"
288create = "authenticated"
289update = "private"
290delete = "owner"
291
292[fields.name]
293type = "string"
294
295[fields.token_hash]
296type = "string"
297required = true
298unique = true
299hidden = true
300
301[fields.owner_id]
302type = "reference"
303references = "user"
304required = true
305"#;
306
307pub const OAUTH_TOML: &str = r#"
309[resource]
310name = "oauth_connection"
311scope = "global"
312timestamps = true
313
314[permissions]
315list = "owner"
316read = "owner"
317create = "private"
318update = "private"
319delete = "owner"
320
321[fields.provider]
322type = "string"
323required = true
324
325[fields.provider_user_id]
326type = "string"
327required = true
328
329[fields.owner_id]
330type = "reference"
331references = "user"
332required = true
333"#;
334
335pub const BILLING_PRODUCT_TOML: &str = r#"
361[resource]
362name = "billing_product"
363scope = "global"
364timestamps = true
365
366[admin]
367label = "Product"
368plural = "Products"
369
370[permissions]
371list = "public" # a pricing page is read by people with no account
372read = "public"
373create = "role:admin"
374update = "role:admin"
375delete = "role:admin"
376
377# Mirror the catalogue into Stripe. Creating the row creates the product;
378# renaming it renames it; archiving it (active = false) archives it there too.
379[hooks]
380before_create = "apiplant_stripe_product"
381before_update = "apiplant_stripe_product"
382
383[fields.name]
384type = "string"
385required = true
386
387[fields.description]
388type = "text"
389
390# Off the price list without deleting the history that points at it. Stripe
391# calls this "archived", and a product with live subscriptions cannot be
392# deleted in either system — only stopped from being bought again.
393[fields.active]
394type = "boolean"
395default = true
396
397# Free-form facts the app checks when deciding what a plan may do: seat
398# limits, feature flags, an internal tier name. Copied to Stripe as metadata
399# so an operator reading either system sees the same thing.
400[fields.features]
401type = "json"
402
403[fields.stripe_product_id]
404type = "string"
405unique = true
406
407[fields.stripe_product_id.admin]
408readonly = true
409help = "Filled in by Stripe when the product is first saved."
410"#;
411
412pub const BILLING_PRICE_TOML: &str = r#"
421[resource]
422name = "billing_price"
423scope = "global"
424timestamps = true
425
426[admin]
427label = "Price"
428plural = "Prices"
429
430[permissions]
431list = "public"
432read = "public"
433create = "role:admin"
434update = "role:admin" # only `active` and presentation: see the hook
435delete = "role:admin"
436
437[hooks]
438before_create = "apiplant_stripe_price"
439before_update = "apiplant_stripe_price"
440
441[fields.product_id]
442type = "reference"
443references = "billing_product"
444required = true
445on_delete = "cascade" # a deleted product takes its price list with it
446
447[fields.nickname]
448type = "string" # "Monthly", "Yearly (2 months free)"
449
450# The charge, in the currency's smallest unit: 1000 = €10.00 = $10.00.
451[fields.unit_amount]
452type = "big_int"
453required = true
454
455[fields.unit_amount.admin]
456help = "In the smallest unit of the currency — 1000 is 10.00."
457
458[fields.currency]
459type = "string"
460max_length = 3 # ISO 4217; empty takes [payments] currency
461
462# How often it recurs: "month", "year", "week", "day", or empty for a price
463# that is charged once. This is what decides whether buying it starts a
464# subscription or takes a single payment.
465[fields.interval]
466type = "string"
467
468[fields.interval.admin]
469options = ["|One-off", "day|Daily", "week|Weekly", "month|Monthly", "year|Yearly"]
470
471# Charge every N intervals — 3 with interval = "month" is quarterly.
472[fields.interval_count]
473type = "integer"
474default = 1
475
476# Days before the first charge. 0 (the default) starts billing immediately.
477[fields.trial_days]
478type = "integer"
479default = 0
480
481# Whether the amount already includes tax. "exclusive" (the default) means tax
482# is added on top, which is what [payments] automatic_tax computes; "inclusive"
483# means the amount is the total and the tax is worked out from within it.
484[fields.tax_behavior]
485type = "string"
486default = "exclusive"
487
488[fields.tax_behavior.admin]
489options = ["exclusive|Tax added on top", "inclusive|Tax included in the amount"]
490
491[fields.active]
492type = "boolean"
493default = true
494
495# Stripe prices are immutable once created: changing an amount there means
496# creating a new price and archiving the old one, which is exactly what the
497# hook does. The id therefore points at whichever price object is current.
498[fields.stripe_price_id]
499type = "string"
500unique = true
501
502[fields.stripe_price_id.admin]
503readonly = true
504help = "Filled in by Stripe. Changing the amount creates a new price and archives the old one."
505"#;
506
507pub const BILLING_CUSTOMER_TOML: &str = r#"
515[resource]
516name = "billing_customer"
517scope = "organization"
518timestamps = true
519
520[admin]
521label = "Billing customer"
522plural = "Billing customers"
523
524[permissions]
525list = "role:admin" # billing is the admins' business
526read = "role:admin"
527create = "private"
528update = "private"
529delete = "private"
530
531[fields.stripe_customer_id]
532type = "string"
533required = true
534unique = true
535
536# Where invoices and receipts go. Defaults to the address of whoever first
537# started a checkout, and is changed from the Stripe portal.
538[fields.email]
539type = "string"
540max_length = 320
541
542[fields.name]
543type = "string"
544
545# The buyer's VAT/GST number, once they have given one. Kept because it is
546# what turns a taxed sale into a reverse-charge one, and support gets asked
547# about it more than anything else on this table.
548[fields.tax_id]
549type = "string"
550
551# Country Stripe places the customer in, for tax. Two-letter ISO 3166-1.
552[fields.tax_country]
553type = "string"
554max_length = 2
555
556# Everything else Stripe holds about the customer, as last delivered. Kept so
557# an operator can answer a billing question without opening two dashboards.
558[fields.details]
559type = "json"
560
561[fields.details.admin]
562visible = false
563"#;
564
565pub const BILLING_SUBSCRIPTION_TOML: &str = r#"
574[resource]
575name = "billing_subscription"
576scope = "organization"
577timestamps = true
578
579[admin]
580label = "Subscription"
581plural = "Subscriptions"
582
583[permissions]
584list = "member" # members can see what plan they are on
585read = "member"
586create = "private" # started by POST /billing/checkout
587update = "private" # written by the Stripe webhook
588delete = "private"
589
590[fields.price_id]
591type = "reference"
592references = "billing_price"
593on_delete = "set_null" # an archived price outlives its row
594
595[fields.customer_id]
596type = "reference"
597references = "billing_customer"
598on_delete = "cascade"
599
600# Stripe's own words: "trialing", "active", "past_due", "canceled",
601# "incomplete", "incomplete_expired", "unpaid", "paused". Anything checking
602# for a paying customer wants "active" or "trialing" — see the `payments`
603# guide for why "past_due" is a judgement call and not a status.
604[fields.status]
605type = "string"
606required = true
607
608[fields.quantity]
609type = "integer"
610default = 1
611
612[fields.current_period_end]
613type = "timestamp"
614
615# Set when the customer has asked to stop but has already paid through the end
616# of the period: still entitled, not renewing.
617[fields.cancel_at_period_end]
618type = "boolean"
619default = false
620
621[fields.trial_ends_at]
622type = "timestamp"
623
624[fields.canceled_at]
625type = "timestamp"
626
627[fields.stripe_subscription_id]
628type = "string"
629required = true
630unique = true
631
632[fields.stripe_subscription_id.admin]
633readonly = true
634"#;
635
636pub const BILLING_PAYMENT_TOML: &str = r#"
643[resource]
644name = "billing_payment"
645scope = "organization"
646timestamps = true
647
648[admin]
649label = "Payment"
650plural = "Payments"
651
652[permissions]
653list = "role:admin"
654read = "role:admin"
655create = "private"
656update = "private"
657delete = "private"
658
659[fields.customer_id]
660type = "reference"
661references = "billing_customer"
662on_delete = "cascade"
663
664[fields.price_id]
665type = "reference"
666references = "billing_price"
667on_delete = "set_null"
668
669[fields.subscription_id]
670type = "reference"
671references = "billing_subscription"
672on_delete = "set_null" # null for a one-off
673
674# What was actually taken, in the smallest unit, and what of it was tax.
675[fields.amount]
676type = "big_int"
677required = true
678
679[fields.tax_amount]
680type = "big_int"
681default = 0
682
683[fields.currency]
684type = "string"
685max_length = 3
686
687# "succeeded", "pending", "failed", "refunded".
688[fields.status]
689type = "string"
690required = true
691
692[fields.description]
693type = "string"
694
695# The buyer's own receipt, hosted by Stripe. Worth storing: it is the link
696# support is asked for, and it outlives the session that produced it.
697[fields.receipt_url]
698type = "string"
699max_length = 2048
700
701[fields.paid_at]
702type = "timestamp"
703
704[fields.stripe_payment_intent_id]
705type = "string"
706unique = true
707
708[fields.stripe_invoice_id]
709type = "string"
710"#;
711
712pub const BILLING_EVENT_TOML: &str = r#"
723[resource]
724name = "billing_event"
725scope = "global"
726timestamps = true
727
728[admin]
729label = "Billing event"
730plural = "Billing events"
731visible = false
732
733[permissions]
734list = "private"
735read = "private"
736create = "private"
737update = "private"
738delete = "private"
739
740[fields.stripe_event_id]
741type = "string"
742required = true
743unique = true
744
745[fields.kind]
746type = "string"
747required = true # "checkout.session.completed", "invoice.paid", …
748
749# When the work finished. A row with this null is an event that arrived and
750# then failed to process — Stripe will retry it, and this is where to look
751# when it stops retrying.
752[fields.processed_at]
753type = "timestamp"
754
755[fields.error]
756type = "text"
757
758[fields.payload]
759type = "json"
760hidden = true
761"#;
762
763pub fn builtins() -> Vec<(&'static str, &'static str)> {
766 vec![
767 ("organization", ORGANIZATION_TOML),
768 ("user", USER_TOML),
769 ("membership", MEMBERSHIP_TOML),
770 ("membership_role", MEMBERSHIP_ROLE_TOML),
771 ("api_key", API_KEY_TOML),
772 ("oauth_connection", OAUTH_TOML),
773 ("invitation", INVITATION_TOML),
774 ("auth_token", AUTH_TOKEN_TOML),
775 ]
776}
777
778pub fn billing_builtins() -> Vec<(&'static str, &'static str)> {
792 vec![
793 ("billing_product", BILLING_PRODUCT_TOML),
794 ("billing_price", BILLING_PRICE_TOML),
795 ("billing_customer", BILLING_CUSTOMER_TOML),
796 ("billing_subscription", BILLING_SUBSCRIPTION_TOML),
797 ("billing_payment", BILLING_PAYMENT_TOML),
798 ("billing_event", BILLING_EVENT_TOML),
799 ]
800}
801
802pub fn parse_builtin(toml_src: &str) -> Resource {
805 let r: Resource = toml::from_str(toml_src).expect("built-in resource TOML is valid");
806 r.validate().expect("built-in resource is valid");
807 r
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813
814 #[test]
815 fn all_builtins_parse() {
816 for (name, src) in builtins().into_iter().chain(billing_builtins()) {
817 let r = parse_builtin(src);
818 assert_eq!(r.meta.name, name);
819 }
820 }
821
822 #[test]
826 fn every_billing_resource_is_namespaced() {
827 for (name, _) in billing_builtins() {
828 assert!(name.starts_with("billing_"), "`{name}` is unprefixed");
829 }
830 }
831
832 #[test]
836 fn billing_references_resolve_within_the_app() {
837 let known: Vec<&str> = builtins()
838 .into_iter()
839 .chain(billing_builtins())
840 .map(|(name, _)| name)
841 .collect();
842 for (name, src) in billing_builtins() {
843 let resource = parse_builtin(src);
844 for (field, spec) in &resource.fields {
845 if let Some(target) = &spec.references {
846 assert!(
847 known.contains(&target.as_str()),
848 "{name}.{field} points at an unknown `{target}`"
849 );
850 }
851 }
852 }
853 }
854}