SaaS Expense Tracker & Budget — Semantic Model
1. Overview
An internal SaaS spend management system that records the company’s SaaS subscriptions (the app, the vendor, the commercial terms, and the contract details on a single record), the departments that own the spend, the budgets planned against them, and which internal users consume seats. Finance and IT use it to track planned vs. expected spend, allocate costs to departments, detect unused licenses, and manage upcoming renewals.
2. Entity summary
| # | Table name | Singular label | Purpose |
|---|---|---|---|
| 1 | vendors | Vendor | The company that sells a SaaS product (e.g. Slack Technologies, Atlassian) |
| 2 | subscriptions | Subscription | A SaaS subscription we pay for — combines the product, commercial terms (seats, price, cadence, dates), and contract details on one record |
| 3 | departments | Department | Cost center / org unit that owns part of the spend |
| 4 | budget_periods | Budget Period | A fiscal year or quarter container for budgeting |
| 5 | budget_lines | Budget Line | Planned spend allocated to a department / subscription for a budget period |
| 6 | license_assignments | License Assignment | Which internal user is consuming a seat on which subscription |
| 7 | users | User | Internal employee — subscription owner, license holder, budget owner, approver |
Entity-relationship diagram
flowchart LR
vendors -->|sells| subscriptions
users -->|owns| subscriptions
users -->|signs| subscriptions
departments -->|funds| subscriptions
users -->|manages| departments
departments -->|parent of| departments
departments -->|employs| users
budget_periods -->|contains| budget_lines
departments -->|budgeted in| budget_lines
subscriptions -->|budgeted in| budget_lines
subscriptions -->|granted via| license_assignments
users -->|holds| license_assignments
3. Entities
3.1 vendors — Vendor
Plural label: Vendors
Label column: vendor_name (the human-identifying field; auto-wired by Semantius)
Description: A company that sells one or more SaaS products we pay for. Created when we onboard a new supplier.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
vendor_name | string | yes | Vendor Name | label_column; unique |
website_url | url | no | Website | |
support_email | email | no | Support Email | |
billing_contact_email | email | no | Billing Contact | |
tax_id | string | no | Tax ID | VAT / EIN |
notes | text | no | Notes |
Do not include
id,created_at,updated_at, or the auto-generatedlabelfield — Semantius creates these automatically.
Relationships
- A
vendormay sell manysubscriptions(1:N, viasubscriptions.vendor_id).
3.2 subscriptions — Subscription
Plural label: Subscriptions
Label column: subscription_name (the human-identifying field; auto-wired by Semantius)
Description: A SaaS subscription we pay for. Each record represents one product-commercial pairing: which app, from which vendor, on what terms (seats, price, cadence, dates), under which contract. Created when a subscription starts; superseded when renewed.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
subscription_name | string | yes | Subscription Name | label_column; e.g. “Slack Business+ — Engineering” |
vendor_id | reference | yes | Vendor | → vendors (N:1, restrict) |
business_owner_id | reference | no | Business Owner | → users (N:1, clear); internal owner responsible for the app |
primary_department_id | reference | no | Owning Department | → departments (N:1, clear) |
signatory_user_id | reference | no | Internal Signatory | → users (N:1, clear); signed the contract |
category | enum | no | Category | values listed in §5.1 |
criticality | enum | no | Criticality | values listed in §5.2 |
description | text | no | Description | |
website_url | url | no | Product Website | |
billing_cycle | enum | yes | Billing Cycle | values listed in §5.3 |
seat_count | integer | no | Seat Count | |
unit_price | float | no | Unit Price | price per seat per billing cycle |
recurring_amount | float | yes | Recurring Amount | total per billing cycle |
currency | string | yes | Currency | ISO 4217 (e.g. USD, EUR) |
start_date | date | yes | Start Date | |
end_date | date | no | End Date | |
auto_renew | boolean | no | Auto-Renew | |
payment_method | enum | no | Payment Method | values listed in §5.4 |
payment_terms | enum | no | Payment Terms | values listed in §5.5 |
contract_number | string | no | Contract Number | from the signed agreement, if any |
signed_date | date | no | Contract Signed Date | |
total_contract_value | float | no | Total Contract Value | whole-contract value if multi-period |
renewal_notice_days | integer | no | Renewal Notice Days | days before end_date to give notice |
negotiated_savings | float | no | Negotiated Savings | vs list price |
document_url | url | no | Contract Document | signed PDF link |
status | enum | yes | Status | values listed in §5.6 |
notes | text | no | Notes |
Relationships
- A
subscriptionbelongs to onevendor(N:1, required, delete: restrict). - A
subscriptionmay have onebusiness_owneruser (N:1, optional, delete: clear). - A
subscriptionmay have oneprimary_department(N:1, optional, delete: clear). - A
subscriptionmay have onesignatory_user(N:1, optional, delete: clear). - A
subscriptionmay appear on manybudget_lines(1:N, viabudget_lines.subscription_id). subscription↔useris many-to-many through thelicense_assignmentsjunction.
3.3 departments — Department
Plural label: Departments
Label column: department_name (the human-identifying field; auto-wired by Semantius)
Description: A cost center or organizational unit against which spend is allocated and budgets are set. Self-referencing for hierarchy.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
department_name | string | yes | Department Name | label_column |
department_code | string | no | Code | unique; e.g. “ENG”, “MKT” |
manager_user_id | reference | no | Manager | → users (N:1, clear) |
parent_department_id | reference | no | Parent Department | → departments (N:1, clear); self-ref for hierarchy |
status | enum | yes | Status | values listed in §5.7 |
Relationships
- A
departmentmay have onemanageruser (N:1, optional, delete: clear). - A
departmentmay have oneparent_department(N:1, optional, delete: clear — self-reference for hierarchy). - A
departmentmay have many child departments (1:N, via self-reference). - A
departmentmay employ manyusers(1:N, viausers.department_id). - A
departmentmay fund manysubscriptions(1:N, viasubscriptions.primary_department_id). - A
departmentmay have manybudget_linesallocated to it (1:N, viabudget_lines.department_id).
3.4 budget_periods — Budget Period
Plural label: Budget Periods
Label column: period_name (the human-identifying field; auto-wired by Semantius)
Description: A time container (fiscal year, quarter, or custom range) inside which budgets are planned and tracked. Created at planning time.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
period_name | string | yes | Period Name | label_column; e.g. “FY2026”, “Q1 2026” |
period_type | enum | yes | Period Type | values listed in §5.8 |
start_date | date | yes | Start Date | |
end_date | date | yes | End Date | |
currency | string | yes | Currency | ISO 4217; reporting currency for this period |
status | enum | yes | Status | values listed in §5.9 |
Relationships
- A
budget_periodmay contain manybudget_lines(1:N, parent, cascade on delete).
3.5 budget_lines — Budget Line
Plural label: Budget Lines
Label column: budget_line_name (the human-identifying field; auto-wired by Semantius)
Description: A single planned spend allocation within a budget period. Typically allocates an amount to a department, a subscription, or a category combination. Created in the context of a budget period.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
budget_line_name | string | yes | Budget Line Name | label_column; e.g. “Engineering — Dev Tools — FY2026” |
budget_period_id | parent | yes | Budget Period | ↳ budget_periods (N:1, cascade) |
department_id | reference | no | Department | → departments (N:1, clear) |
subscription_id | reference | no | Subscription | → subscriptions (N:1, clear); null if allocated at category level |
category | enum | no | Category | values listed in §5.10 |
planned_amount | float | yes | Planned Amount | |
currency | string | yes | Currency | ISO 4217 |
notes | text | no | Notes |
Relationships
- A
budget_linebelongs to onebudget_period(N:1, required, delete: cascade — parent). - A
budget_linemay link to onedepartment(N:1, optional, delete: clear). - A
budget_linemay link to onesubscription(N:1, optional, delete: clear).
3.6 license_assignments — License Assignment
Plural label: License Assignments
Label column: assignment_label (the human-identifying field; auto-wired by Semantius)
Description: Junction record showing that a specific user is consuming a seat of a specific subscription. Used for per-seat chargeback and unused-license detection. Caller populates assignment_label on create (e.g. "{user.full_name} / {subscription.subscription_name}") because the junction has no natural string key.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
assignment_label | string | yes | Assignment | label_column; caller-populated scalar (must not be a FK per Semantius label rules) |
subscription_id | parent | yes | Subscription | ↳ subscriptions (N:1, cascade) |
user_id | parent | yes | User | ↳ users (N:1, cascade) |
assigned_date | date | no | Assigned Date | |
last_active_date | date | no | Last Active | for unused-license detection |
monthly_cost_allocation | float | no | Monthly Cost Allocation | per-seat chargeback |
status | enum | yes | Status | values listed in §5.11 |
Relationships
- A
license_assignmentbelongs to onesubscription(N:1, required, delete: cascade — parent). - A
license_assignmentbelongs to oneuser(N:1, required, delete: cascade — parent). - Together the two parent FKs form the M:N junction between
subscriptionsandusers.
3.7 users — User
Plural label: Users
Label column: full_name (the human-identifying field; auto-wired by Semantius)
Description: An internal employee who may own subscriptions, sign contracts, manage departments, or hold license assignments. The table_name matches the Semantius built-in users table so the downstream implementer can deduplicate — fields here describe the domain-required shape; the implementer reconciles with built-in fields and adds only what’s missing.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
full_name | string | yes | Full Name | label_column |
email | email | yes | unique | |
department_id | reference | no | Department | → departments (N:1, clear) |
job_title | string | no | Job Title | |
employee_id | string | no | Employee ID | unique |
status | enum | yes | Status | values listed in §5.12 |
Relationships
- A
usermay belong to onedepartment(N:1, optional, delete: clear). - A
usermay manage manydepartments(1:N, viadepartments.manager_user_id). - A
usermay be the business owner of manysubscriptions(1:N, viasubscriptions.business_owner_id). - A
usermay be signatory on manysubscriptions(1:N, viasubscriptions.signatory_user_id). user↔subscriptionis many-to-many through thelicense_assignmentsjunction.
4. Relationship summary
| From | Field | To | Cardinality | Kind | Delete behavior |
|---|---|---|---|---|---|
subscriptions | vendor_id | vendors | N:1 | reference | restrict |
subscriptions | business_owner_id | users | N:1 | reference | clear |
subscriptions | primary_department_id | departments | N:1 | reference | clear |
subscriptions | signatory_user_id | users | N:1 | reference | clear |
departments | manager_user_id | users | N:1 | reference | clear |
departments | parent_department_id | departments | N:1 | reference | clear |
budget_lines | budget_period_id | budget_periods | N:1 | parent | cascade |
budget_lines | department_id | departments | N:1 | reference | clear |
budget_lines | subscription_id | subscriptions | N:1 | reference | clear |
license_assignments | subscription_id | subscriptions | N:1 | parent (junction) | cascade |
license_assignments | user_id | users | N:1 | parent (junction) | cascade |
users | department_id | departments | N:1 | reference | clear |
5. Enumerations
5.1 subscriptions.category
communicationdev_toolsproductivitymarketingsaleshrfinancesecuritydesignanalyticsinfrastructureother
5.2 subscriptions.criticality
criticalimportantnice_to_have
5.3 subscriptions.billing_cycle
monthlyquarterlyannualmulti_yearone_time
5.4 subscriptions.payment_method
credit_cardachwireinvoicepurchase_order
5.5 subscriptions.payment_terms
net_15net_30net_60net_90prepaidmonthlyquarterlyannual
5.6 subscriptions.status
activetrialingpendingcancelledexpireddeprecatedarchived
5.7 departments.status
activeinactive
5.8 budget_periods.period_type
fiscal_yearquartermonthcustom
5.9 budget_periods.status
draftopenclosedarchived
5.10 budget_lines.category
communicationdev_toolsproductivitymarketingsaleshrfinancesecuritydesignanalyticsinfrastructureotherunallocated
(Note: shares most values with subscriptions.category plus unallocated for budget lines not tied to a specific category.)
5.11 license_assignments.status
activeinactivependingrevoked
5.12 users.status
activeinactiveoffboarded
6. Open questions
6.1 🔴 Decisions needed
None.
6.2 🟡 Future considerations
- Should an
exchange_rates(date, from_currency, to_currency, rate) entity be added for multi-currency reporting?currencyis stored per record as an ISO 4217 string, but there is no FX table — budget-vs-actual in a single reporting currency would require one. - Should
invoicesandinvoice_line_itemsbe added once AP-level tracking (paid vs. due, dispute handling, line-level allocations) is in scope? This model intentionally omits them; “expected” spend is computed fromsubscriptions.recurring_amount× cadence across a budget period, not from received bills. - Should contracts be promoted out of
subscriptionsinto a separatecontractsentity to support MSAs covering multiple sub-products? A subscription currently carries its owncontract_number,signed_date,document_url,total_contract_value,renewal_notice_days,negotiated_savings— fine for 1:1 contract-to-subscription, but not for one contract spanning several subscriptions. - Should product identity be re-split from commercial terms into a separate
saas_applicationsentity? A singlesubscriptionsrecord carries both product and terms; multiple concurrent subscriptions for the same product work as multiple rows, but product-level reporting (without double-counting) would require the split. - Should
approval_requests/purchase_ordersbe modelled in this system? No approval workflow entities are present; significant addition if purchase, renewal, or budget-change approvals must be tracked here rather than in a separate tool. - Should a
usage_eventsentity be added for richer engagement analytics?license_assignments.last_active_datecaptures a single timestamp for basic unused-license detection; per-user activity or feature adoption would need a dedicated event log. - Should
subscriptions.categoryandbudget_lines.categorybe promoted to a shared lookup table to avoid enum drift? The two enums diverge only in thatbudget_lineshasunallocated; a lookup table would keep them aligned as the taxonomy evolves.
7. Implementation notes for the downstream agent
- Create one module named
saas_expense_trackerand two baseline permissions (saas_expense_tracker:read,saas_expense_tracker:manage) before any entity. - Create entities in this order so referenced tables exist first:
departments→users→vendors→subscriptions→budget_periods→budget_lines→license_assignments. Note thatdepartments↔usershave a mutual reference (departments.manager_user_idandusers.department_id); create both entities first, then add the cross-references as a second pass. - For each entity: set
label_columnto the snake_case field marked as label in §3, passmodule_id,view_permission: "saas_expense_tracker:read",edit_permission: "saas_expense_tracker:manage". Do not manually createid,created_at,updated_at, or the auto-label field. - For each field in §3: pass
table_name,field_name,format,title(the Label column),is_nullable(inverse of Required), and forreference/parentfields alsoreference_tableand areference_delete_modeconsistent with §4. - Fix up label-column titles.
create_entityauto-creates a field whosefield_nameequalslabel_columnand whosetitledefaults tosingular_label. Every entity in this model has a Label for the label_column row that differs fromsingular_label(intentional —singular_labelstays a bare singular for plural/singular symmetry, while the field-level title is more specific). After eachcreate_entity, callupdate_fieldwith the composite string id"{table_name}.{label_column}"(passed as a string, not an integer) to set the correct title:"vendors.vendor_name"→"Vendor Name""subscriptions.subscription_name"→"Subscription Name""departments.department_name"→"Department Name""budget_periods.period_name"→"Period Name""budget_lines.budget_line_name"→"Budget Line Name""license_assignments.assignment_label"→"Assignment""users.full_name"→"Full Name"
- Deduplicate against Semantius built-in tables. This model declares
userswhich already exists as a Semantius built-in. Read the built-in first: if it already covers the required shape, skip thecreate_entitycall and reuse the built-in as thereference_tabletarget fordepartments.manager_user_id,subscriptions.business_owner_id,subscriptions.signatory_user_id,license_assignments.user_id, andusers.department_id. Add only the missing fields in §3.7 that are not present on the built-in (department_id,job_title,employee_idare the likely additions; check before creating each). Additive changes only. If the built-in is reused, skip the step 5update_fieldfor"users.full_name"unless the built-in’s existing title needs correcting. - Caller-populated label columns. Two entities have label_columns with no natural source field; front-end callers must populate them on create:
license_assignments.assignment_label— e.g."{user.full_name} / {subscription.subscription_name}"(junction has no natural string identifier).budget_lines.budget_line_name— e.g."{department.department_name} — {category} — {budget_period.period_name}"(no single source field identifies a budget line).
- After creation, spot-check that
label_columnon each entity resolves to a real field, that allreference_tabletargets exist, and that each label-column field’stitlematches the §3 Label (notsingular_label).