Skip to main content

rustlavel_billing/
schema.rs

1//! The three tables.
2
3use rustlavel_core::Result;
4use rustlavel_db::{Schema, Table};
5
6pub const SUBSCRIPTIONS: &str = "billing_subscriptions";
7pub const INVOICES: &str = "billing_invoices";
8pub const REMINDERS: &str = "billing_reminders";
9
10pub fn define_subscriptions(t: &mut Table) {
11    t.id();
12    t.string("subscription_id").unique();
13    t.string("customer");
14    t.string("customer_name");
15    t.string("customer_email").nullable();
16    t.string("customer_phone").nullable();
17    t.string("plan_id");
18    t.string("next_plan_id").nullable();
19    // The status is the lock for every transition: `UPDATE … WHERE status =
20    // 'active'` and one row changed means this scheduler moved it, not the
21    // other one running at the same moment.
22    t.string("status");
23    t.string("channel");
24    t.big_integer("period_start").default_int(0);
25    t.big_integer("period_end").default_int(0);
26    t.boolean("cancel_at_period_end").default_bool(false);
27    t.big_integer("created_at");
28    t.big_integer("updated_at");
29    t.index(&["customer"]);
30    t.index(&["status", "period_end"]);
31}
32
33pub fn define_invoices(t: &mut Table) {
34    t.id();
35    t.string("invoice_id").unique();
36    t.string("subscription_id");
37    t.string("customer");
38    t.string("plan_id");
39    t.big_integer("number");
40    t.big_integer("amount_minor");
41    t.string("currency");
42    t.big_integer("credits");
43    t.big_integer("period_start").default_int(0);
44    t.big_integer("period_end").default_int(0);
45    // The claim. While an invoice is open this is the period it is for, so
46    // the unique index below allows one open invoice per period per
47    // subscription — two schedulers raising the same renewal both write the
48    // same key and one is refused. When the invoice stops being open the key
49    // is rewritten to `<status>:<invoice_id>`, which collides with nothing,
50    // so a voided renewal does not block the one raised in its place.
51    t.string("period_key");
52    // `UPDATE … WHERE status = 'open'` on payment: one row changed means this
53    // webhook is the one that paid it, and a retry changes nothing.
54    t.string("status");
55    t.string("charge_id").nullable();
56    t.string("channel");
57    t.json("instructions").nullable();
58    t.big_integer("due_at");
59    t.big_integer("paid_at").nullable();
60    t.big_integer("created_at");
61    t.unique(&["subscription_id", "period_key"]);
62    // Numbers are read-then-incremented; the period key is what makes that
63    // safe, but a duplicate number would still be a lie on a receipt.
64    t.unique(&["subscription_id", "number"]);
65    t.index(&["status", "due_at"]);
66}
67
68pub fn define_reminders(t: &mut Table) {
69    t.id();
70    t.string("invoice_id");
71    t.string("kind");
72    t.big_integer("sent_at");
73    // A reminder is emitted once. The insert is the claim; the index is the
74    // rule.
75    t.unique(&["invoice_id", "kind"]);
76}
77
78pub async fn create_tables(schema: &Schema<'_>) -> Result<()> {
79    schema.create(SUBSCRIPTIONS, define_subscriptions).await?;
80    schema.create(INVOICES, define_invoices).await?;
81    schema.create(REMINDERS, define_reminders).await
82}
83
84pub async fn drop_tables(schema: &Schema<'_>) -> Result<()> {
85    schema.drop(REMINDERS).await?;
86    schema.drop(INVOICES).await?;
87    schema.drop(SUBSCRIPTIONS).await
88}
89
90rustlavel_db::migration!(
91    CreateBillingTables,
92    "2026_09_17_000200_create_billing_tables",
93    up: |schema| { crate::schema::create_tables(schema).await },
94    down: |schema| { crate::schema::drop_tables(schema).await },
95);