diff --git a/drizzle/migrations/0009_eminent_wild_pack.sql b/drizzle/migrations/0009_eminent_wild_pack.sql
new file mode 100644
index 0000000..4d1f3f3
--- /dev/null
+++ b/drizzle/migrations/0009_eminent_wild_pack.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `finance_settings` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `tax_percent_bps` integer DEFAULT 1100 NOT NULL,
+ `shipping_percent_bps` integer DEFAULT 2000 NOT NULL,
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
+);
diff --git a/drizzle/migrations/meta/0009_snapshot.json b/drizzle/migrations/meta/0009_snapshot.json
new file mode 100644
index 0000000..fdc2365
--- /dev/null
+++ b/drizzle/migrations/meta/0009_snapshot.json
@@ -0,0 +1,1609 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "8e304c55-9e95-4399-94dc-e6aa2727175c",
+ "prevId": "75c0bcfa-844e-417d-b4d3-b41242a7261a",
+ "tables": {
+ "api_key": {
+ "name": "api_key",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_prefix": {
+ "name": "key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_revoked": {
+ "name": "is_revoked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "api_key_key_hash_unique": {
+ "name": "api_key_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "api_key_user_id_user_id_fk": {
+ "name": "api_key_user_id_user_id_fk",
+ "tableFrom": "api_key",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "finance_settings": {
+ "name": "finance_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tax_percent_bps": {
+ "name": "tax_percent_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1100
+ },
+ "shipping_percent_bps": {
+ "name": "shipping_percent_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2000
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "gift_fund": {
+ "name": "gift_fund",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "current_value_cents": {
+ "name": "current_value_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "gift_fund_log": {
+ "name": "gift_fund_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "changed_by": {
+ "name": "changed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "change_type": {
+ "name": "change_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_value_cents": {
+ "name": "previous_value_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "new_value_cents": {
+ "name": "new_value_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "order_id": {
+ "name": "order_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "gift_fund_log_changed_by_user_id_fk": {
+ "name": "gift_fund_log_changed_by_user_id_fk",
+ "tableFrom": "gift_fund_log",
+ "tableTo": "user",
+ "columnsFrom": [
+ "changed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "gift_fund_log_order_id_orders_id_fk": {
+ "name": "gift_fund_log_order_id_orders_id_fk",
+ "tableFrom": "gift_fund_log",
+ "tableTo": "orders",
+ "columnsFrom": [
+ "order_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "minecraft_whitelist": {
+ "name": "minecraft_whitelist",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "request_note": {
+ "name": "request_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "admin_note": {
+ "name": "admin_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "added_directly": {
+ "name": "added_directly",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "minecraft_whitelist_user_id_user_id_fk": {
+ "name": "minecraft_whitelist_user_id_user_id_fk",
+ "tableFrom": "minecraft_whitelist",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "minecraft_whitelist_reviewed_by_user_id_fk": {
+ "name": "minecraft_whitelist_reviewed_by_user_id_fk",
+ "tableFrom": "minecraft_whitelist",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "network_join_request": {
+ "name": "network_join_request",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "device_name": {
+ "name": "device_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "machine_key": {
+ "name": "machine_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_note": {
+ "name": "request_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "admin_note": {
+ "name": "admin_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "network_join_request_user_id_user_id_fk": {
+ "name": "network_join_request_user_id_user_id_fk",
+ "tableFrom": "network_join_request",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "network_join_request_reviewed_by_user_id_fk": {
+ "name": "network_join_request_reviewed_by_user_id_fk",
+ "tableFrom": "network_join_request",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orders": {
+ "name": "orders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fund_type": {
+ "name": "fund_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stf_bucket_id": {
+ "name": "stf_bucket_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quarter_id": {
+ "name": "quarter_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_name": {
+ "name": "item_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_number": {
+ "name": "part_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "denial_comment": {
+ "name": "denial_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "orders_user_id_user_id_fk": {
+ "name": "orders_user_id_user_id_fk",
+ "tableFrom": "orders",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "orders_stf_bucket_id_stf_bucket_id_fk": {
+ "name": "orders_stf_bucket_id_stf_bucket_id_fk",
+ "tableFrom": "orders",
+ "tableTo": "stf_bucket",
+ "columnsFrom": [
+ "stf_bucket_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "orders_quarter_id_stf_quarter_id_fk": {
+ "name": "orders_quarter_id_stf_quarter_id_fk",
+ "tableFrom": "orders",
+ "tableTo": "stf_quarter",
+ "columnsFrom": [
+ "quarter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "orders_reviewed_by_user_id_fk": {
+ "name": "orders_reviewed_by_user_id_fk",
+ "tableFrom": "orders",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "order_history": {
+ "name": "order_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "order_id": {
+ "name": "order_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_status": {
+ "name": "from_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "to_status": {
+ "name": "to_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "changed_by": {
+ "name": "changed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "changed_at": {
+ "name": "changed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "order_history_order_id_orders_id_fk": {
+ "name": "order_history_order_id_orders_id_fk",
+ "tableFrom": "order_history",
+ "tableTo": "orders",
+ "columnsFrom": [
+ "order_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "order_history_changed_by_user_id_fk": {
+ "name": "order_history_changed_by_user_id_fk",
+ "tableFrom": "order_history",
+ "tableTo": "user",
+ "columnsFrom": [
+ "changed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "stf_bucket": {
+ "name": "stf_bucket",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "quarter_id": {
+ "name": "quarter_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "starting_balance_cents": {
+ "name": "starting_balance_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "stf_bucket_quarter_id_stf_quarter_id_fk": {
+ "name": "stf_bucket_quarter_id_stf_quarter_id_fk",
+ "tableFrom": "stf_bucket",
+ "tableTo": "stf_quarter",
+ "columnsFrom": [
+ "quarter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "stf_quarter": {
+ "name": "stf_quarter",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "stf_quarter_name_unique": {
+ "name": "stf_quarter_name_unique",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "team": {
+ "name": "team",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "team_name_unique": {
+ "name": "team_name_unique",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_feature": {
+ "name": "user_feature",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feature_key": {
+ "name": "feature_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "request_note": {
+ "name": "request_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "admin_note": {
+ "name": "admin_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_at": {
+ "name": "requested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "user_feature_unique": {
+ "name": "user_feature_unique",
+ "columns": [
+ "user_id",
+ "feature_key"
+ ],
+ "isUnique": true
+ },
+ "user_feature_userId_idx": {
+ "name": "user_feature_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "user_feature_user_id_user_id_fk": {
+ "name": "user_feature_user_id_user_id_fk",
+ "tableFrom": "user_feature",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_feature_reviewed_by_user_id_fk": {
+ "name": "user_feature_reviewed_by_user_id_fk",
+ "tableFrom": "user_feature",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "vault_entry": {
+ "name": "vault_entry",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secret": {
+ "name": "secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "vault_entry_created_by_user_id_fk": {
+ "name": "vault_entry_created_by_user_id_fk",
+ "tableFrom": "vault_entry",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "vault_entry_access": {
+ "name": "vault_entry_access",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "entry_id": {
+ "name": "entry_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "vault_entry_access_entry_user": {
+ "name": "vault_entry_access_entry_user",
+ "columns": [
+ "entry_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "vault_entry_access_entry_id_vault_entry_id_fk": {
+ "name": "vault_entry_access_entry_id_vault_entry_id_fk",
+ "tableFrom": "vault_entry_access",
+ "tableTo": "vault_entry",
+ "columnsFrom": [
+ "entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "vault_entry_access_user_id_user_id_fk": {
+ "name": "vault_entry_access_user_id_user_id_fk",
+ "tableFrom": "vault_entry_access",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "vault_entry_access_granted_by_user_id_fk": {
+ "name": "vault_entry_access_granted_by_user_id_fk",
+ "tableFrom": "vault_entry_access",
+ "tableTo": "user",
+ "columnsFrom": [
+ "granted_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": true
+ },
+ "can_access_vault": {
+ "name": "can_access_vault",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "approved": {
+ "name": "approved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json
index bfdec52..5bb0070 100644
--- a/drizzle/migrations/meta/_journal.json
+++ b/drizzle/migrations/meta/_journal.json
@@ -64,6 +64,13 @@
"when": 1782000000000,
"tag": "0008_order_form_finance",
"breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "6",
+ "when": 1782000000001,
+ "tag": "0009_eminent_wild_pack",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/scripts/seed.ts b/scripts/seed.ts
index 9a20200..387e152 100644
--- a/scripts/seed.ts
+++ b/scripts/seed.ts
@@ -26,8 +26,10 @@ const TEAMS = [
async function main() {
const { auth } = await import("../src/lib/auth/auth");
const { db } = await import("../src/lib/db");
- const { giftFund, stfBucket, stfQuarter, team, user } = await import("../src/lib/db/schema");
+ const { giftFund, order, stfBucket, stfQuarter, team, user } =
+ await import("../src/lib/db/schema");
const { GIFT_FUND_ID } = await import("../src/lib/finance/finance");
+ const { financeSettings } = await import("../src/lib/db/schema");
const { eq } = await import("drizzle-orm");
for (const name of TEAMS) {
@@ -76,6 +78,18 @@ async function main() {
}
console.log("Seeded STF buckets.");
+ const existingSettings = db
+ .select()
+ .from(financeSettings)
+ .where(eq(financeSettings.id, 1))
+ .get();
+ if (!existingSettings) {
+ db.insert(financeSettings)
+ .values({ id: 1, taxPercentBps: 1100, shippingPercentBps: 2000 })
+ .run();
+ console.log("Seeded finance settings.");
+ }
+
const rawEmail = process.env.SEED_ADMIN_EMAIL;
const email = rawEmail?.includes("@") ? rawEmail : `${rawEmail}@admin.local`;
const password = process.env.SEED_ADMIN_PASSWORD;
@@ -98,6 +112,228 @@ async function main() {
.run();
console.log(`Ensured ${email} has role=admin, isActive=true, approved=true.`);
+
+ const adminUser = db.select().from(user).where(eq(user.email, email)).get();
+ const mechanical = db.select().from(stfBucket).where(eq(stfBucket.name, "Mechanical")).get();
+ const electronics = db.select().from(stfBucket).where(eq(stfBucket.name, "Electronics")).get();
+
+ const SEED_ITEM_PREFIX = "[seed] ";
+ const seedOrders = [
+ {
+ itemName: `${SEED_ITEM_PREFIX}1/4-20 hex bolt assortment`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "McMaster-Carr",
+ link: "https://example.com/mcmaster-bolts",
+ partNumber: "91290A115",
+ quantity: 2,
+ unitCostCents: 2450,
+ notes: "Assorted lengths for drivetrain assembly",
+ status: "ordered" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}REV NEO brushless motor`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "REV Robotics",
+ link: "https://example.com/rev-neo",
+ partNumber: "REV-21-1650",
+ quantity: 4,
+ unitCostCents: 12_500,
+ notes: null,
+ status: "ordered" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Pit organization tape`,
+ fundType: "Gift" as const,
+ stfBucketName: null,
+ vendor: "Amazon",
+ link: "https://example.com/pit-tape",
+ partNumber: null,
+ quantity: 6,
+ unitCostCents: 899,
+ notes: "Colored tape for pit cable management",
+ status: "ordered" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Aluminum 1x1x1/8 wall tube`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "Online Metals",
+ link: "https://example.com/aluminum-tube",
+ partNumber: "OM-1X1-125",
+ quantity: 3,
+ unitCostCents: 18_750,
+ notes: "Frame rail stock",
+ status: "ordered" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Limelight 3 vision camera`,
+ fundType: "STF" as const,
+ stfBucketName: "Electronics",
+ vendor: "Limelight",
+ link: "https://example.com/limelight-3",
+ partNumber: "LL3",
+ quantity: 1,
+ unitCostCents: 39_900,
+ notes: null,
+ status: "ordered" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Polycarbonate sheet 1/4"`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "TAP Plastics",
+ link: "https://example.com/polycarbonate",
+ partNumber: "PC-025",
+ quantity: 2,
+ unitCostCents: 6200,
+ notes: "Bumper backing plate material",
+ status: "approved" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Kraken X60 motor controller`,
+ fundType: "STF" as const,
+ stfBucketName: "Electronics",
+ vendor: "WCP",
+ link: "https://example.com/kraken-x60",
+ partNumber: "WCP-KRAKEN-X60",
+ quantity: 2,
+ unitCostCents: 14_900,
+ notes: "Drivetrain motor controllers",
+ status: "approved" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}NEO Vortex motor`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "REV Robotics",
+ link: "https://example.com/neo-vortex",
+ partNumber: "REV-21-1651",
+ quantity: 2,
+ unitCostCents: 11_200,
+ notes: null,
+ status: "approved" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Blue Nitrile gloves (case)`,
+ fundType: "Gift" as const,
+ stfBucketName: null,
+ vendor: "Uline",
+ link: "https://example.com/nitrile-gloves",
+ partNumber: null,
+ quantity: 1,
+ unitCostCents: 3200,
+ notes: "Pit safety supplies",
+ status: "approved" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Zip ties assortment`,
+ fundType: "Gift" as const,
+ stfBucketName: null,
+ vendor: "Amazon",
+ link: "https://example.com/zip-ties",
+ partNumber: null,
+ quantity: 3,
+ unitCostCents: 1299,
+ notes: "Cable management for robot and pit",
+ status: "approved" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}VersaHub gearbox kit`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "VEXPro",
+ link: "https://example.com/versahub",
+ partNumber: "217-7050",
+ quantity: 1,
+ unitCostCents: 8750,
+ notes: "Elevator pivot gearbox",
+ status: "approved" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}CAN wire spool`,
+ fundType: "STF" as const,
+ stfBucketName: "Electronics",
+ vendor: "West Coast Products",
+ link: "https://example.com/can-wire",
+ partNumber: "WCP-CAN-50",
+ quantity: 1,
+ unitCostCents: 4500,
+ notes: null,
+ status: "pending" as const,
+ },
+ {
+ itemName: `${SEED_ITEM_PREFIX}Over-budget titanium fastener`,
+ fundType: "STF" as const,
+ stfBucketName: "Mechanical",
+ vendor: "McMaster-Carr",
+ link: "https://example.com/titanium-bolt",
+ partNumber: "91290A999",
+ quantity: 1,
+ unitCostCents: 99_900,
+ notes: "Denied — use steel alternative",
+ status: "denied" as const,
+ denialComment: "Too expensive for this application. Resubmit with steel hardware.",
+ },
+ ];
+
+ if (adminUser && quarter && mechanical && electronics) {
+ const reviewedAt = new Date("2025-10-15");
+ const bucketByName = {
+ Mechanical: mechanical.id,
+ Electronics: electronics.id,
+ };
+ let inserted = 0;
+ let reset = 0;
+
+ for (const seed of seedOrders) {
+ const stfBucketId =
+ seed.fundType === "STF" && seed.stfBucketName
+ ? bucketByName[seed.stfBucketName as keyof typeof bucketByName]
+ : null;
+
+ const values = {
+ userId: adminUser.id,
+ fundType: seed.fundType,
+ stfBucketId,
+ quarterId: seed.fundType === "STF" ? quarter.id : null,
+ vendor: seed.vendor,
+ link: seed.link,
+ itemName: seed.itemName,
+ partNumber: seed.partNumber,
+ quantity: seed.quantity,
+ unitCostCents: seed.unitCostCents,
+ notes: seed.notes,
+ status: seed.status,
+ denialComment: "denialComment" in seed ? seed.denialComment : null,
+ reviewedBy: seed.status !== "pending" ? adminUser.id : null,
+ reviewedAt: seed.status !== "pending" ? reviewedAt : null,
+ };
+
+ const exists = db.select().from(order).where(eq(order.itemName, seed.itemName)).get();
+ if (exists) {
+ db.update(order).set(values).where(eq(order.id, exists.id)).run();
+ reset++;
+ continue;
+ }
+
+ db.insert(order).values(values).run();
+ inserted++;
+ }
+
+ const ordered = seedOrders.filter((o) => o.status === "ordered").length;
+ const approved = seedOrders.filter((o) => o.status === "approved").length;
+ const pending = seedOrders.filter((o) => o.status === "pending").length;
+ const denied = seedOrders.filter((o) => o.status === "denied").length;
+
+ if (inserted > 0 || reset > 0) {
+ console.log(
+ `Sample orders: ${inserted} inserted, ${reset} reset (${ordered} ordered, ${approved} approved, ${pending} pending, ${denied} denied).`
+ );
+ }
+ }
+
console.log("Seed complete.");
}
diff --git a/src/app/(dashboard)/admin/finance/page.tsx b/src/app/(dashboard)/admin/finance/page.tsx
index bf5d108..7addc06 100644
--- a/src/app/(dashboard)/admin/finance/page.tsx
+++ b/src/app/(dashboard)/admin/finance/page.tsx
@@ -3,14 +3,18 @@ import { desc } from "drizzle-orm";
import { FinanceManager } from "@/components/finance/FinanceManager";
import { db } from "@/lib/db";
import {
+ ensureFinanceSettingsRow,
ensureGiftFundRow,
getGiftFundValueCents,
+ getOrderPricingSettings,
getStfBucketsWithBalances,
} from "@/lib/finance/finance";
+import { percentBpsToDisplay } from "@/lib/finance/order-pricing";
import { giftFundLog, stfQuarter } from "@/lib/db/schema";
export default async function AdminFinancePage() {
ensureGiftFundRow();
+ ensureFinanceSettingsRow();
const quarters = db.select().from(stfQuarter).orderBy(desc(stfQuarter.createdAt)).all();
const giftLog = db
@@ -20,12 +24,14 @@ export default async function AdminFinancePage() {
.limit(50)
.all();
+ const pricing = getOrderPricingSettings();
+
return (
Finance
- Manage STF buckets, gift fund value, and school year resets.
+ Manage STF buckets, gift fund value, order pricing, and school year resets.
@@ -35,6 +41,10 @@ export default async function AdminFinancePage() {
stfBuckets: getStfBucketsWithBalances(),
quarters,
giftLog,
+ orderPricing: {
+ taxPercent: percentBpsToDisplay(pricing.taxPercentBps),
+ shippingPercent: percentBpsToDisplay(pricing.shippingPercentBps),
+ },
}}
/>
diff --git a/src/app/(dashboard)/admin/orders/page.tsx b/src/app/(dashboard)/admin/orders/page.tsx
index 9ed86e0..6b3bd8e 100644
--- a/src/app/(dashboard)/admin/orders/page.tsx
+++ b/src/app/(dashboard)/admin/orders/page.tsx
@@ -3,8 +3,12 @@ import { asc, desc, eq, sql } from "drizzle-orm";
import { AdminOrderQueue, type AdminOrderRow } from "@/components/orders/AdminOrderQueue";
import { db } from "@/lib/db";
import { order, stfBucket, user } from "@/lib/db/schema";
+import { ensureFinanceSettingsRow, getOrderPricingSettings } from "@/lib/finance/finance";
+import { percentBpsToDisplay } from "@/lib/finance/order-pricing";
export default async function AdminOrdersPage() {
+ ensureFinanceSettingsRow();
+ const pricing = getOrderPricingSettings();
const rows: AdminOrderRow[] = db
.select({
id: order.id,
@@ -37,11 +41,18 @@ export default async function AdminOrdersPage() {
Order Queue
- Review pending orders and browse the archive of approved and denied requests.
+ Review pending orders, manage approved batches, and browse ordered and denied
+ archives.
-
+
);
}
diff --git a/src/app/(dashboard)/orders/[id]/edit/page.tsx b/src/app/(dashboard)/orders/[id]/edit/page.tsx
index 178b6c4..260078a 100644
--- a/src/app/(dashboard)/orders/[id]/edit/page.tsx
+++ b/src/app/(dashboard)/orders/[id]/edit/page.tsx
@@ -26,7 +26,7 @@ export default async function EditOrderPage({ params }: PageProps) {
.get();
if (!existing) notFound();
- if (existing.status === "approved") {
+ if (existing.status === "approved" || existing.status === "ordered") {
redirect("/orders");
}
diff --git a/src/app/(dashboard)/orders/page.tsx b/src/app/(dashboard)/orders/page.tsx
index 71a50ce..0535592 100644
--- a/src/app/(dashboard)/orders/page.tsx
+++ b/src/app/(dashboard)/orders/page.tsx
@@ -8,13 +8,21 @@ import { TeamOrderTable, type TeamOrderRow } from "@/components/orders/TeamOrder
import { Button } from "@/components/ui/button";
import { db } from "@/lib/db";
import { order, stfBucket, user as userTable } from "@/lib/db/schema";
-import { getGiftFundValueCents, getStfBucketsWithBalances } from "@/lib/finance/finance";
+import {
+ getGiftFundValueCents,
+ ensureFinanceSettingsRow,
+ getOrderPricingSettings,
+ getStfBucketsWithBalances,
+} from "@/lib/finance/finance";
+import { percentBpsToDisplay } from "@/lib/finance/order-pricing";
import { getSessionUser } from "@/lib/auth/session";
export default async function OrdersPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
+ ensureFinanceSettingsRow();
+
const myOrders: MemberOrderRow[] = db
.select({
id: order.id,
@@ -54,6 +62,12 @@ export default async function OrdersPage() {
const giftBalanceCents = getGiftFundValueCents();
const stfBuckets = getStfBucketsWithBalances();
+ const pricing = getOrderPricingSettings();
+ const orderPricing = {
+ taxPercent: percentBpsToDisplay(pricing.taxPercentBps),
+ shippingPercent: percentBpsToDisplay(pricing.shippingPercentBps),
+ };
+ const orderedParts = teamOrders.filter((o) => o.status === "ordered");
return (
@@ -78,7 +92,7 @@ export default async function OrdersPage() {
Every order you've submitted, across all statuses.
-
+
@@ -89,7 +103,22 @@ export default async function OrdersPage() {
these in the order queue.
-
+
+
+
+
+
+
Ordered parts
+
+ Parts that officers have approved and placed with vendors.
+
+
+
);
diff --git a/src/app/api/admin/finance/route.ts b/src/app/api/admin/finance/route.ts
index 93d2be3..b75a492 100644
--- a/src/app/api/admin/finance/route.ts
+++ b/src/app/api/admin/finance/route.ts
@@ -3,10 +3,13 @@ import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import {
+ ensureFinanceSettingsRow,
+ ensureGiftFundRow,
getGiftFundValueCents,
+ getOrderPricingSettings,
getStfBucketsWithBalances,
- ensureGiftFundRow,
} from "@/lib/finance/finance";
+import { percentBpsToDisplay } from "@/lib/finance/order-pricing";
import { giftFundLog, stfQuarter } from "@/lib/db/schema";
import { getSessionUser } from "@/lib/auth/session";
@@ -16,6 +19,7 @@ export async function GET() {
if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
ensureGiftFundRow();
+ ensureFinanceSettingsRow();
const quarters = db.select().from(stfQuarter).orderBy(desc(stfQuarter.createdAt)).all();
const giftLog = db
@@ -25,10 +29,16 @@ export async function GET() {
.limit(50)
.all();
+ const pricing = getOrderPricingSettings();
+
return NextResponse.json({
giftBalanceCents: getGiftFundValueCents(),
stfBuckets: getStfBucketsWithBalances(),
quarters,
giftLog,
+ orderPricing: {
+ taxPercent: percentBpsToDisplay(pricing.taxPercentBps),
+ shippingPercent: percentBpsToDisplay(pricing.shippingPercentBps),
+ },
});
}
diff --git a/src/app/api/admin/finance/settings/route.ts b/src/app/api/admin/finance/settings/route.ts
new file mode 100644
index 0000000..e235b89
--- /dev/null
+++ b/src/app/api/admin/finance/settings/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+import { getOrderPricingSettings, updateOrderPricingSettings } from "@/lib/finance/finance";
+import { displayPercentToBps, percentBpsToDisplay } from "@/lib/finance/order-pricing";
+import { getSessionUser } from "@/lib/auth/session";
+import { financeSettingsUpdateSchema } from "@/lib/validation";
+
+export async function PATCH(req: NextRequest) {
+ const user = await getSessionUser();
+ if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const body = await req.json().catch(() => null);
+ const parsed = financeSettingsUpdateSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ updateOrderPricingSettings({
+ taxPercentBps: displayPercentToBps(parsed.data.taxPercent),
+ shippingPercentBps: displayPercentToBps(parsed.data.shippingPercent),
+ });
+
+ const settings = getOrderPricingSettings();
+ return NextResponse.json({
+ taxPercent: percentBpsToDisplay(settings.taxPercentBps),
+ shippingPercent: percentBpsToDisplay(settings.shippingPercentBps),
+ });
+}
diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts
index 4036782..0dd4693 100644
--- a/src/app/api/orders/[id]/route.ts
+++ b/src/app/api/orders/[id]/route.ts
@@ -3,7 +3,12 @@ import { NextResponse, type NextRequest } from "next/server";
import { db } from "@/lib/db";
import { order, orderHistory } from "@/lib/db/schema";
-import { getActiveQuarter, orderTotalCents, validateOrderBalance } from "@/lib/finance/finance";
+import {
+ getActiveQuarter,
+ orderTotalCents,
+ restoreGiftFundForDeletion,
+ validateOrderBalance,
+} from "@/lib/finance/finance";
import { getSessionUser } from "@/lib/auth/session";
import { orderInputSchema } from "@/lib/validation";
@@ -13,8 +18,12 @@ function parseOrderId(params: { id: string }) {
return orderId;
}
+function isLockedOrderStatus(status: string) {
+ return status === "approved" || status === "ordered";
+}
+
function memberCanModifyOrder(existing: { userId: string; status: string }, userId: string) {
- return existing.userId === userId && existing.status !== "approved";
+ return existing.userId === userId && !isLockedOrderStatus(existing.status);
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -112,10 +121,21 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{
if (!existing) {
return NextResponse.json({ error: "Order not found" }, { status: 404 });
}
- if (!memberCanModifyOrder(existing, user.id)) {
+
+ const isAdmin = user.role === "admin";
+ if (isLockedOrderStatus(existing.status)) {
+ if (!isAdmin) {
+ return NextResponse.json({ error: "This order cannot be deleted" }, { status: 403 });
+ }
+ } else if (!memberCanModifyOrder(existing, user.id)) {
return NextResponse.json({ error: "This order cannot be deleted" }, { status: 403 });
}
+ if (isLockedOrderStatus(existing.status) && existing.fundType === "Gift") {
+ const totalCostCents = orderTotalCents(existing.quantity, existing.unitCostCents);
+ restoreGiftFundForDeletion(orderId, totalCostCents, user.id);
+ }
+
db.delete(order).where(eq(order.id, orderId)).run();
return new NextResponse(null, { status: 204 });
diff --git a/src/app/api/orders/balances/route.ts b/src/app/api/orders/balances/route.ts
index bb042b5..cedf682 100644
--- a/src/app/api/orders/balances/route.ts
+++ b/src/app/api/orders/balances/route.ts
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server";
-import { getGiftFundValueCents, getStfBucketsWithBalances } from "@/lib/finance/finance";
+import {
+ ensureFinanceSettingsRow,
+ getGiftFundValueCents,
+ getOrderPricingSettings,
+ getStfBucketsWithBalances,
+} from "@/lib/finance/finance";
+import { percentBpsToDisplay } from "@/lib/finance/order-pricing";
import { getSessionUser } from "@/lib/auth/session";
export async function GET() {
@@ -9,8 +15,15 @@ export async function GET() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ ensureFinanceSettingsRow();
+ const pricing = getOrderPricingSettings();
+
return NextResponse.json({
giftBalanceCents: getGiftFundValueCents(),
stfBuckets: getStfBucketsWithBalances(),
+ orderPricing: {
+ taxPercent: percentBpsToDisplay(pricing.taxPercentBps),
+ shippingPercent: percentBpsToDisplay(pricing.shippingPercentBps),
+ },
});
}
diff --git a/src/app/api/orders/mark-ordered/route.ts b/src/app/api/orders/mark-ordered/route.ts
new file mode 100644
index 0000000..d25b16a
--- /dev/null
+++ b/src/app/api/orders/mark-ordered/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+import { markApprovedOrdersAsOrdered } from "@/lib/finance/finance";
+import { getSessionUser } from "@/lib/auth/session";
+import { markOrderedSchema } from "@/lib/validation";
+
+export async function POST(req: NextRequest) {
+ const user = await getSessionUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (user.role !== "admin") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const parsed = markOrderedSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const movedCount = markApprovedOrdersAsOrdered(user.id, parsed.data.orderIds);
+ return NextResponse.json({ movedCount });
+}
diff --git a/src/components/finance/FinanceManager.tsx b/src/components/finance/FinanceManager.tsx
index 2a9d105..5690e13 100644
--- a/src/components/finance/FinanceManager.tsx
+++ b/src/components/finance/FinanceManager.tsx
@@ -24,6 +24,11 @@ import {
} from "@/components/ui/table";
import { formatDate, formatPriceCents } from "@/lib/utils";
+type OrderPricing = {
+ taxPercent: number;
+ shippingPercent: number;
+};
+
type StfBucket = {
id: number;
name: string;
@@ -54,6 +59,7 @@ export type FinanceData = {
stfBuckets: StfBucket[];
quarters: Quarter[];
giftLog: GiftLogEntry[];
+ orderPricing: OrderPricing;
};
export function FinanceManager({ initial }: { initial: FinanceData }) {
@@ -68,13 +74,20 @@ export function FinanceManager({ initial }: { initial: FinanceData }) {
const [resetStep, setResetStep] = useState<0 | 1 | 2>(0);
const [resetConfirmName, setResetConfirmName] = useState("");
const [nextQuarterName, setNextQuarterName] = useState("");
+ const [taxPercent, setTaxPercent] = useState(String(initial.orderPricing.taxPercent));
+ const [shippingPercent, setShippingPercent] = useState(
+ String(initial.orderPricing.shippingPercent)
+ );
const activeQuarter = data.quarters.find((q) => q.isActive);
async function refresh() {
const res = await fetch("/api/admin/finance");
if (res.ok) {
- setData(await res.json());
+ const next = await res.json();
+ setData(next);
+ setTaxPercent(String(next.orderPricing.taxPercent));
+ setShippingPercent(String(next.orderPricing.shippingPercent));
router.refresh();
}
}
@@ -180,6 +193,34 @@ export function FinanceManager({ initial }: { initial: FinanceData }) {
}
}
+ async function saveOrderPricing() {
+ const tax = Number(taxPercent);
+ const shipping = Number(shippingPercent);
+ if (Number.isNaN(tax) || tax < 0 || Number.isNaN(shipping) || shipping < 0) {
+ toast.error("Tax and shipping must be zero or positive");
+ return;
+ }
+
+ setBusy("pricing");
+ try {
+ const res = await fetch("/api/admin/finance/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ taxPercent: tax, shippingPercent: shipping }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => null);
+ throw new Error(err?.error ?? "Failed to update order pricing");
+ }
+ toast.success("Order pricing updated");
+ await refresh();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setBusy(null);
+ }
+ }
+
async function resetQuarter() {
if (!activeQuarter) return;
setBusy("reset");
@@ -321,6 +362,43 @@ export function FinanceManager({ initial }: { initial: FinanceData }) {
) : null}
+
+
+
Order pricing
+
+ Tax and shipping percentages applied to order totals for balance checks and
+ spend tracking.
+
+
+
+
+ Tax (%)
+ setTaxPercent(e.target.value)}
+ />
+
+
+ Shipping (%)
+ setShippingPercent(e.target.value)}
+ />
+
+
+ Save pricing
+
+
+
+
Gift fund
diff --git a/src/components/orders/AdminOrderQueue.tsx b/src/components/orders/AdminOrderQueue.tsx
index 2306b3d..1eae7ef 100644
--- a/src/components/orders/AdminOrderQueue.tsx
+++ b/src/components/orders/AdminOrderQueue.tsx
@@ -1,7 +1,7 @@
"use client";
-import { Copy } from "lucide-react";
-import { Fragment } from "react";
+import { Copy, PackageCheck, Trash2 } from "lucide-react";
+import { Fragment, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
@@ -23,6 +23,11 @@ import {
formatApprovedStfOrders,
formatOrderForExcel,
} from "@/lib/finance/order-export";
+import {
+ computeOrderTotalCents,
+ displayPercentToBps,
+ type OrderPricingSettings,
+} from "@/lib/finance/order-pricing";
import { formatDate, formatPriceCents } from "@/lib/utils";
import { OrderStatusBadge } from "./OrderStatusBadge";
@@ -47,8 +52,11 @@ export type AdminOrderRow = {
type Action = "approve" | "deny";
-function totalCostCents(row: { quantity: number; unitCostCents: number }) {
- return row.quantity * row.unitCostCents;
+function toPricingSettings(orderPricing: OrderPricing): OrderPricingSettings {
+ return {
+ taxPercentBps: displayPercentToBps(orderPricing.taxPercent),
+ shippingPercentBps: displayPercentToBps(orderPricing.shippingPercent),
+ };
}
async function copyText(text: string, label: string) {
@@ -64,20 +72,33 @@ async function copyText(text: string, label: string) {
}
}
-export function AdminOrderQueue({ orders }: { orders: AdminOrderRow[] }) {
+export type OrderPricing = {
+ taxPercent: number;
+ shippingPercent: number;
+};
+
+export function AdminOrderQueue({
+ orders,
+ orderPricing,
+}: {
+ orders: AdminOrderRow[];
+ orderPricing: OrderPricing;
+}) {
const router = useRouter();
const [expandedId, setExpandedId] = useState(null);
const [denialComment, setDenialComment] = useState("");
const [pending, setPending] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [markingOrdered, setMarkingOrdered] = useState(false);
+ const [selectedApprovedIds, setSelectedApprovedIds] = useState>(new Set());
+ const pricingSettings = toPricingSettings(orderPricing);
const pendingOrders = orders.filter((o) => o.status === "pending");
- const otherOrders = orders.filter((o) => o.status !== "pending");
- const approvedStfCount = orders.filter(
- (o) => o.status === "approved" && o.fundType === "STF"
- ).length;
- const approvedGiftCount = orders.filter(
- (o) => o.status === "approved" && o.fundType === "Gift"
- ).length;
+ const approvedOrders = orders.filter((o) => o.status === "approved");
+ const orderedOrders = orders.filter((o) => o.status === "ordered");
+ const deniedOrders = orders.filter((o) => o.status === "denied");
+ const approvedStfCount = approvedOrders.filter((o) => o.fundType === "STF").length;
+ const approvedGiftCount = approvedOrders.filter((o) => o.fundType === "Gift").length;
async function runAction(order: AdminOrderRow, action: Action) {
setPending(action);
@@ -105,6 +126,83 @@ export function AdminOrderQueue({ orders }: { orders: AdminOrderRow[] }) {
}
}
+ async function markApprovedAsOrdered(orderIds?: number[]) {
+ const count = orderIds?.length ?? approvedOrders.length;
+ if (count === 0) return;
+
+ const message =
+ count === 1
+ ? "Move 1 approved order to the ordered archive? It will no longer appear in Excel exports."
+ : `Move ${count} approved order${count === 1 ? "" : "s"} to the ordered archive? They will no longer appear in Excel exports.`;
+ if (!confirm(message)) return;
+
+ setMarkingOrdered(true);
+ try {
+ const res = await fetch("/api/orders/mark-ordered", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(orderIds ? { orderIds } : {}),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ throw new Error(data?.error ?? "Failed to mark orders as ordered");
+ }
+ const data = await res.json();
+ toast.success(
+ data.movedCount === 1
+ ? "1 order moved to ordered"
+ : `${data.movedCount} orders moved to ordered`
+ );
+ setExpandedId(null);
+ setSelectedApprovedIds(new Set());
+ router.refresh();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setMarkingOrdered(false);
+ }
+ }
+
+ function toggleApprovedSelection(orderId: number) {
+ setSelectedApprovedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(orderId)) next.delete(orderId);
+ else next.add(orderId);
+ return next;
+ });
+ }
+
+ function toggleAllApprovedSelection() {
+ setSelectedApprovedIds((prev) => {
+ if (prev.size === approvedOrders.length) return new Set();
+ return new Set(approvedOrders.map((o) => o.id));
+ });
+ }
+
+ async function deleteOrder(order: AdminOrderRow) {
+ const message =
+ order.status === "approved" || order.status === "ordered"
+ ? `Delete ${order.status} order for "${order.itemName}"? This removes it from the archive and restores any gift fund deduction.`
+ : `Delete order for "${order.itemName}"? This cannot be undone.`;
+ if (!confirm(message)) return;
+
+ setDeletingId(order.id);
+ try {
+ const res = await fetch(`/api/orders/${order.id}`, { method: "DELETE" });
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ throw new Error(data?.error ?? "Failed to delete order");
+ }
+ toast.success("Order deleted");
+ setExpandedId(null);
+ router.refresh();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
if (orders.length === 0) {
return (
@@ -115,7 +213,7 @@ export function AdminOrderQueue({ orders }: { orders: AdminOrderRow[] }) {
return (
- {(approvedStfCount > 0 || approvedGiftCount > 0) && (
+ {(approvedStfCount > 0 || approvedGiftCount > 0 || approvedOrders.length > 0) && (
copyText(
- formatApprovedStfOrders(orders),
+ formatApprovedStfOrders(orders, pricingSettings),
`${approvedStfCount} approved STF order${approvedStfCount === 1 ? "" : "s"}`
)
}
@@ -145,6 +243,14 @@ export function AdminOrderQueue({ orders }: { orders: AdminOrderRow[] }) {
Copy all approved Gift ({approvedGiftCount})
+
markApprovedAsOrdered()}
+ >
+
+ Mark all approved as ordered ({approvedOrders.length})
+
)}
- {otherOrders.length > 0 ? (
+ {approvedOrders.length > 0 ? (
+
setExpandedId((prev) => (prev === id ? null : id))}
+ denialComment={denialComment}
+ onDenialCommentChange={setDenialComment}
+ pending={pending}
+ onAction={runAction}
+ showActions={false}
+ orderPricing={orderPricing}
+ onDelete={deleteOrder}
+ deletingId={deletingId}
+ selection={{
+ selectedIds: selectedApprovedIds,
+ onToggle: toggleApprovedSelection,
+ onToggleAll: toggleAllApprovedSelection,
+ allSelected:
+ selectedApprovedIds.size === approvedOrders.length &&
+ approvedOrders.length > 0,
+ someSelected:
+ selectedApprovedIds.size > 0 &&
+ selectedApprovedIds.size < approvedOrders.length,
+ }}
+ headerAction={
+ markApprovedAsOrdered(Array.from(selectedApprovedIds))}
+ >
+
+ Move selected to ordered ({selectedApprovedIds.size})
+
+ }
+ />
+ ) : null}
+ {deniedOrders.length > 0 ? (
setExpandedId((prev) => (prev === id ? null : id))}
denialComment={denialComment}
@@ -172,6 +317,26 @@ export function AdminOrderQueue({ orders }: { orders: AdminOrderRow[] }) {
pending={pending}
onAction={runAction}
showActions={false}
+ orderPricing={orderPricing}
+ onDelete={deleteOrder}
+ deletingId={deletingId}
+ />
+ ) : null}
+ {orderedOrders.length > 0 ? (
+ setExpandedId((prev) => (prev === id ? null : id))}
+ denialComment={denialComment}
+ onDenialCommentChange={setDenialComment}
+ pending={pending}
+ onAction={runAction}
+ showActions={false}
+ orderPricing={orderPricing}
+ onDelete={deleteOrder}
+ deletingId={deletingId}
/>
) : null}
@@ -180,6 +345,7 @@ export function AdminOrderQueue({ orders }: { orders: AdminOrderRow[] }) {
function OrderSection({
title,
+ description,
orders,
expandedId,
onToggle,
@@ -188,8 +354,14 @@ function OrderSection({
pending,
onAction,
showActions,
+ orderPricing,
+ onDelete,
+ deletingId,
+ selection,
+ headerAction,
}: {
title: string;
+ description?: string;
orders: AdminOrderRow[];
expandedId: number | null;
onToggle: (id: number) => void;
@@ -198,16 +370,52 @@ function OrderSection({
pending: Action | null;
onAction: (order: AdminOrderRow, action: Action) => void;
showActions: boolean;
+ orderPricing: OrderPricing;
+ onDelete?: (order: AdminOrderRow) => void;
+ deletingId?: number | null;
+ selection?: {
+ selectedIds: Set
;
+ onToggle: (id: number) => void;
+ onToggleAll: () => void;
+ allSelected: boolean;
+ someSelected: boolean;
+ };
+ headerAction?: ReactNode;
}) {
if (orders.length === 0) return null;
+ const pricingSettings = toPricingSettings(orderPricing);
+ const columnCount = selection ? 7 : 6;
+
return (
-
{title}
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {headerAction ?
{headerAction}
: null}
+
+ {selection ? (
+
+ {
+ if (el) el.indeterminate = selection.someSelected;
+ }}
+ onChange={selection.onToggleAll}
+ className="border-input size-4 rounded border"
+ />
+
+ ) : null}
Submitted by
Item
Fund / bucket
@@ -228,6 +436,17 @@ function OrderSection({
className="cursor-pointer"
onClick={() => onToggle(o.id)}
>
+ {selection ? (
+ e.stopPropagation()}>
+ selection.onToggle(o.id)}
+ className="border-input size-4 rounded border"
+ />
+
+ ) : null}
{o.requesterName ?? o.requesterEmail ?? "-"}
@@ -239,7 +458,13 @@ function OrderSection({
{o.stfBucketName ? ` · ${o.stfBucketName}` : ""}
- {formatPriceCents(totalCostCents(o))}
+ {formatPriceCents(
+ computeOrderTotalCents(
+ o.quantity,
+ o.unitCostCents,
+ pricingSettings
+ )
+ )}
{formatDate(o.createdAt)}
@@ -250,7 +475,10 @@ function OrderSection({
{expanded ? (
-
+
@@ -279,6 +510,9 @@ function OrderDetail({
onDenialCommentChange,
pending,
onAction,
+ orderPricing,
+ onDelete,
+ deletingId,
}: {
order: AdminOrderRow;
showActions: boolean;
@@ -286,8 +520,13 @@ function OrderDetail({
onDenialCommentChange: (v: string) => void;
pending: Action | null;
onAction: (order: AdminOrderRow, action: Action) => void;
+ orderPricing: OrderPricing;
+ onDelete?: (order: AdminOrderRow) => void;
+ deletingId?: number | null;
}) {
- const excelRow = formatOrderForExcel(order);
+ const pricingSettings = toPricingSettings(orderPricing);
+ const excelRow = formatOrderForExcel(order, false, pricingSettings);
+ const total = computeOrderTotalCents(order.quantity, order.unitCostCents, pricingSettings);
return (
@@ -295,7 +534,7 @@ function OrderDetail({
-
+
Link
@@ -374,6 +613,18 @@ function OrderDetail({
>
+ ) : onDelete ? (
+
e.stopPropagation()}>
+ onDelete(order)}
+ disabled={deletingId === order.id}
+ >
+
+ Delete order
+
+
) : null}
);
diff --git a/src/components/orders/OrderForm.tsx b/src/components/orders/OrderForm.tsx
index 3ee0855..596b113 100644
--- a/src/components/orders/OrderForm.tsx
+++ b/src/components/orders/OrderForm.tsx
@@ -33,6 +33,7 @@ import {
StfBucketSelectItemContent,
} from "@/components/BalanceAmount";
import type { FundType, OrderStatus } from "@/lib/db/schema";
+import { computeOrderTotalCents, displayPercentToBps } from "@/lib/finance/order-pricing";
import { cn, formatPriceCents } from "@/lib/utils";
type StfBucketBalance = {
@@ -44,6 +45,10 @@ type StfBucketBalance = {
type Balances = {
giftBalanceCents: number;
stfBuckets: StfBucketBalance[];
+ orderPricing: {
+ taxPercent: number;
+ shippingPercent: number;
+ };
};
const formSchema = z
@@ -159,9 +164,15 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial })
const totalCostCents = useMemo(() => {
const qty = Number(quantity);
const cost = Number(unitCost);
+ const pricing = balances?.orderPricing;
if (!Number.isFinite(qty) || !Number.isFinite(cost) || qty < 1 || cost <= 0) return null;
- return Math.round(qty * cost * 100);
- }, [quantity, unitCost]);
+ if (!pricing) return null;
+ const unitCostCents = Math.round(cost * 100);
+ return computeOrderTotalCents(qty, unitCostCents, {
+ taxPercentBps: displayPercentToBps(pricing.taxPercent),
+ shippingPercentBps: displayPercentToBps(pricing.shippingPercent),
+ });
+ }, [quantity, unitCost, balances?.orderPricing]);
const balanceError = useMemo(() => {
if (!fundType || totalCostCents == null || !balances) return null;
@@ -425,7 +436,7 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial })
{totalCostCents != null ? (
- Total cost:{" "}
+ Total cost (incl. tax & shipping):{" "}
{formatPriceCents(totalCostCents)}
diff --git a/src/components/orders/OrderStatusBadge.tsx b/src/components/orders/OrderStatusBadge.tsx
index 72a533b..cb5de0f 100644
--- a/src/components/orders/OrderStatusBadge.tsx
+++ b/src/components/orders/OrderStatusBadge.tsx
@@ -12,6 +12,7 @@ const STATUS: Record<
> = {
pending: { label: "Pending", variant: "outline" },
approved: { label: "Approved", variant: "default" },
+ ordered: { label: "Ordered", variant: "secondary" },
denied: { label: "Denied", variant: "destructive" },
};
diff --git a/src/components/orders/OrderTable.tsx b/src/components/orders/OrderTable.tsx
index dfb7033..95373f4 100644
--- a/src/components/orders/OrderTable.tsx
+++ b/src/components/orders/OrderTable.tsx
@@ -23,6 +23,11 @@ import {
TableRow,
} from "@/components/ui/table";
import type { FundType, OrderStatus } from "@/lib/db/schema";
+import {
+ computeOrderTotalCents,
+ displayPercentToBps,
+ type OrderPricingSettings,
+} from "@/lib/finance/order-pricing";
import { formatDate, formatPriceCents } from "@/lib/utils";
import { OrderStatusBadge } from "./OrderStatusBadge";
@@ -43,20 +48,36 @@ type StatusFilter = "all" | OrderStatus;
type FundFilter = "all" | FundType;
type SortKey = "newest" | "oldest" | "item-asc" | "item-desc" | "total-desc" | "total-asc";
-function totalCostCents(row: { quantity: number; unitCostCents: number }) {
- return row.quantity * row.unitCostCents;
+function totalCostCents(
+ row: { quantity: number; unitCostCents: number },
+ pricing: OrderPricingSettings
+) {
+ return computeOrderTotalCents(row.quantity, row.unitCostCents, pricing);
}
function canModifyOrder(status: OrderStatus) {
return status === "pending" || status === "denied";
}
-export function OrderTable({ orders }: { orders: MemberOrderRow[] }) {
+export function OrderTable({
+ orders,
+ orderPricing,
+}: {
+ orders: MemberOrderRow[];
+ orderPricing: { taxPercent: number; shippingPercent: number };
+}) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState("all");
const [fundFilter, setFundFilter] = useState("all");
const [sortKey, setSortKey] = useState("newest");
const [deletingId, setDeletingId] = useState(null);
+ const pricingSettings = useMemo(
+ () => ({
+ taxPercentBps: displayPercentToBps(orderPricing.taxPercent),
+ shippingPercentBps: displayPercentToBps(orderPricing.shippingPercent),
+ }),
+ [orderPricing.taxPercent, orderPricing.shippingPercent]
+ );
const filteredOrders = useMemo(() => {
let rows = orders.filter((order) => {
@@ -74,9 +95,9 @@ export function OrderTable({ orders }: { orders: MemberOrderRow[] }) {
case "item-desc":
return b.itemName.localeCompare(a.itemName);
case "total-desc":
- return totalCostCents(b) - totalCostCents(a);
+ return totalCostCents(b, pricingSettings) - totalCostCents(a, pricingSettings);
case "total-asc":
- return totalCostCents(a) - totalCostCents(b);
+ return totalCostCents(a, pricingSettings) - totalCostCents(b, pricingSettings);
case "newest":
default:
return b.createdAt.getTime() - a.createdAt.getTime();
@@ -84,7 +105,7 @@ export function OrderTable({ orders }: { orders: MemberOrderRow[] }) {
});
return rows;
- }, [fundFilter, orders, sortKey, statusFilter]);
+ }, [fundFilter, orders, pricingSettings, sortKey, statusFilter]);
async function handleDelete(order: MemberOrderRow) {
if (!confirm(`Delete your order for "${order.itemName}"? This cannot be undone.`)) return;
@@ -117,6 +138,7 @@ export function OrderTable({ orders }: { orders: MemberOrderRow[] }) {
all: "All statuses",
pending: "Pending",
approved: "Approved",
+ ordered: "Ordered",
denied: "Denied",
};
@@ -150,6 +172,7 @@ export function OrderTable({ orders }: { orders: MemberOrderRow[] }) {
All statuses
Pending
Approved
+ Ordered
Denied
@@ -221,7 +244,7 @@ export function OrderTable({ orders }: { orders: MemberOrderRow[] }) {
{o.stfBucketName ? ` · ${o.stfBucketName}` : ""}
- {formatPriceCents(totalCostCents(o))}
+ {formatPriceCents(totalCostCents(o, pricingSettings))}
diff --git a/src/components/orders/TeamOrderTable.tsx b/src/components/orders/TeamOrderTable.tsx
index ae68070..da0b4ee 100644
--- a/src/components/orders/TeamOrderTable.tsx
+++ b/src/components/orders/TeamOrderTable.tsx
@@ -18,6 +18,11 @@ import {
TableRow,
} from "@/components/ui/table";
import type { FundType, OrderStatus } from "@/lib/db/schema";
+import {
+ computeOrderTotalCents,
+ displayPercentToBps,
+ type OrderPricingSettings,
+} from "@/lib/finance/order-pricing";
import { formatDate, formatPriceCents } from "@/lib/utils";
import { OrderStatusBadge } from "./OrderStatusBadge";
@@ -39,18 +44,38 @@ type StatusFilter = "all" | OrderStatus;
type FundFilter = "all" | FundType;
type SortKey = "newest" | "oldest" | "item-asc" | "item-desc" | "total-desc" | "total-asc";
-function totalCostCents(row: { quantity: number; unitCostCents: number }) {
- return row.quantity * row.unitCostCents;
+function totalCostCents(
+ row: { quantity: number; unitCostCents: number },
+ pricing: OrderPricingSettings
+) {
+ return computeOrderTotalCents(row.quantity, row.unitCostCents, pricing);
}
function requesterLabel(row: TeamOrderRow) {
return row.requesterName ?? row.requesterEmail ?? "-";
}
-export function TeamOrderTable({ orders }: { orders: TeamOrderRow[] }) {
+export function TeamOrderTable({
+ orders,
+ orderPricing,
+ showFilters = true,
+ emptyMessage,
+}: {
+ orders: TeamOrderRow[];
+ orderPricing: { taxPercent: number; shippingPercent: number };
+ showFilters?: boolean;
+ emptyMessage?: string;
+}) {
const [statusFilter, setStatusFilter] = useState("all");
const [fundFilter, setFundFilter] = useState("all");
const [sortKey, setSortKey] = useState("newest");
+ const pricingSettings = useMemo(
+ () => ({
+ taxPercentBps: displayPercentToBps(orderPricing.taxPercent),
+ shippingPercentBps: displayPercentToBps(orderPricing.shippingPercent),
+ }),
+ [orderPricing.taxPercent, orderPricing.shippingPercent]
+ );
const filteredOrders = useMemo(() => {
let rows = orders.filter((order) => {
@@ -68,9 +93,9 @@ export function TeamOrderTable({ orders }: { orders: TeamOrderRow[] }) {
case "item-desc":
return b.itemName.localeCompare(a.itemName);
case "total-desc":
- return totalCostCents(b) - totalCostCents(a);
+ return totalCostCents(b, pricingSettings) - totalCostCents(a, pricingSettings);
case "total-asc":
- return totalCostCents(a) - totalCostCents(b);
+ return totalCostCents(a, pricingSettings) - totalCostCents(b, pricingSettings);
case "newest":
default:
return b.createdAt.getTime() - a.createdAt.getTime();
@@ -78,12 +103,12 @@ export function TeamOrderTable({ orders }: { orders: TeamOrderRow[] }) {
});
return rows;
- }, [fundFilter, orders, sortKey, statusFilter]);
+ }, [fundFilter, orders, pricingSettings, sortKey, statusFilter]);
if (orders.length === 0) {
return (
- No orders have been submitted yet.
+ {emptyMessage ?? "No orders have been submitted yet."}
);
}
@@ -92,6 +117,7 @@ export function TeamOrderTable({ orders }: { orders: TeamOrderRow[] }) {
all: "All statuses",
pending: "Pending",
approved: "Approved",
+ ordered: "Ordered",
denied: "Denied",
};
@@ -112,56 +138,59 @@ export function TeamOrderTable({ orders }: { orders: TeamOrderRow[] }) {
return (
-
- setStatusFilter(value as StatusFilter)}
- >
-
-
-
-
- All statuses
- Pending
- Approved
- Denied
-
-
-
- setFundFilter(value as FundFilter)}
- >
-
-
-
-
- All funds
- STF
- Gift
-
-
-
- setSortKey(value as SortKey)}
- >
-
-
-
-
- Newest first
- Oldest first
- Item A–Z
- Item Z–A
- Highest total
- Lowest total
-
-
-
+ {showFilters ? (
+
+ setStatusFilter(value as StatusFilter)}
+ >
+
+
+
+
+ All statuses
+ Pending
+ Approved
+ Ordered
+ Denied
+
+
+
+ setFundFilter(value as FundFilter)}
+ >
+
+
+
+
+ All funds
+ STF
+ Gift
+
+
+
+ setSortKey(value as SortKey)}
+ >
+
+
+
+
+ Newest first
+ Oldest first
+ Item A–Z
+ Item Z–A
+ Highest total
+ Lowest total
+
+
+
+ ) : null}
{filteredOrders.length === 0 ? (
@@ -198,7 +227,7 @@ export function TeamOrderTable({ orders }: { orders: TeamOrderRow[] }) {
{o.stfBucketName ? ` · ${o.stfBucketName}` : ""}
- {formatPriceCents(totalCostCents(o))}
+ {formatPriceCents(totalCostCents(o, pricingSettings))}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index f6d839e..27e8e0d 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -6,9 +6,9 @@ import { user } from "./auth-schema";
const now = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
-export type OrderStatus = "pending" | "approved" | "denied";
+export type OrderStatus = "pending" | "approved" | "denied" | "ordered";
export type FundType = "STF" | "Gift";
-export type GiftFundChangeType = "order_approved" | "manual_adjustment";
+export type GiftFundChangeType = "order_approved" | "order_deleted" | "manual_adjustment";
export type WhitelistStatus = "pending" | "approved" | "rejected";
export type JoinRequestStatus = "pending" | "approved" | "rejected";
export type VaultEntryType = "login" | "api_key";
@@ -47,6 +47,16 @@ export const giftFund = sqliteTable("gift_fund", {
.notNull(),
});
+export const financeSettings = sqliteTable("finance_settings", {
+ id: integer("id").primaryKey(),
+ taxPercentBps: integer("tax_percent_bps").notNull().default(1100),
+ shippingPercentBps: integer("shipping_percent_bps").notNull().default(2000),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
+ .default(now)
+ .$onUpdate(() => new Date())
+ .notNull(),
+});
+
export const order = sqliteTable("orders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
diff --git a/src/lib/finance/finance.test.ts b/src/lib/finance/finance.test.ts
index d066395..ae1ac97 100644
--- a/src/lib/finance/finance.test.ts
+++ b/src/lib/finance/finance.test.ts
@@ -1,11 +1,51 @@
-import { describe, it, expect } from "vitest";
-import { orderTotalCents } from "./finance";
+import { and, eq } from "drizzle-orm";
+import { afterEach, beforeAll, describe, expect, it } from "vitest";
+
+import { db } from "@/lib/db";
+import { giftFund, giftFundLog, order, orderHistory, stfBucket, user } from "@/lib/db/schema";
+import {
+ deductGiftFundForApproval,
+ ensureFinanceSettingsRow,
+ ensureGiftFundRow,
+ getActiveQuarter,
+ getBucketApprovedSpendCents,
+ getOrderPricingSettings,
+ GIFT_FUND_ID,
+ markApprovedOrdersAsOrdered,
+ orderTotalCents,
+ restoreGiftFundForDeletion,
+ updateOrderPricingSettings,
+} from "./finance";
+import { DEFAULT_ORDER_PRICING } from "./order-pricing";
+
+const TEST_ITEM_PREFIX = "vitest-order-";
+
+function cleanupTestOrders() {
+ const rows = db
+ .select({ id: order.id, itemName: order.itemName })
+ .from(order)
+ .all()
+ .filter((row) => row.itemName.startsWith(TEST_ITEM_PREFIX));
+
+ for (const { id } of rows) {
+ db.delete(orderHistory).where(eq(orderHistory.orderId, id)).run();
+ db.delete(giftFundLog).where(eq(giftFundLog.orderId, id)).run();
+ db.delete(order).where(eq(order.id, id)).run();
+ }
+}
+
+beforeAll(() => {
+ ensureFinanceSettingsRow();
+ ensureGiftFundRow();
+});
+
+afterEach(() => {
+ cleanupTestOrders();
+});
describe("orderTotalCents", () => {
- it("multiplies quantity by unit cost in cents", () => {
- expect(orderTotalCents(2, 5000)).toBe(10000);
- expect(orderTotalCents(1, 9999)).toBe(9999);
- expect(orderTotalCents(10, 100)).toBe(1000);
+ it("includes default tax and shipping on the subtotal", () => {
+ expect(orderTotalCents(2, 5000)).toBe(13_100);
});
it("returns 0 when quantity is 0", () => {
@@ -17,9 +57,254 @@ describe("orderTotalCents", () => {
});
it("handles large quantities and costs without overflow", () => {
- // 9999 units × $9999.99 ceiling — should not produce NaN or Infinity
const result = orderTotalCents(9999, 999999);
expect(Number.isFinite(result)).toBe(true);
- expect(result).toBe(9999 * 999999);
+ expect(result).toBe(
+ computeExpectedTotal(
+ 9999,
+ 999999,
+ DEFAULT_ORDER_PRICING.taxPercentBps,
+ DEFAULT_ORDER_PRICING.shippingPercentBps
+ )
+ );
+ });
+});
+
+describe("updateOrderPricingSettings", () => {
+ it("persists custom tax and shipping rates", () => {
+ const previous = getOrderPricingSettings();
+ updateOrderPricingSettings({ taxPercentBps: 500, shippingPercentBps: 1000 });
+
+ expect(getOrderPricingSettings()).toEqual({ taxPercentBps: 500, shippingPercentBps: 1000 });
+ expect(orderTotalCents(1, 10_000)).toBe(11_500);
+
+ updateOrderPricingSettings(previous);
+ expect(getOrderPricingSettings()).toEqual(previous);
+ });
+});
+
+describe("getBucketApprovedSpendCents", () => {
+ it("counts both approved and ordered orders toward bucket spend", () => {
+ const quarter = getActiveQuarter();
+ const requester = db.select().from(user).limit(1).get();
+ const bucketRecord = db
+ .select()
+ .from(stfBucket)
+ .where(and(eq(stfBucket.isActive, true)))
+ .limit(1)
+ .get();
+
+ if (!quarter || !requester || !bucketRecord) return;
+
+ const spendBefore = getBucketApprovedSpendCents(bucketRecord.id, quarter.id);
+
+ const approved = db
+ .insert(order)
+ .values({
+ userId: requester.id,
+ fundType: "STF",
+ stfBucketId: bucketRecord.id,
+ quarterId: quarter.id,
+ vendor: "Test Vendor",
+ link: "https://example.com/part",
+ itemName: `${TEST_ITEM_PREFIX}approved`,
+ partNumber: "TEST-001",
+ quantity: 1,
+ unitCostCents: 1000,
+ status: "approved",
+ })
+ .returning()
+ .get();
+
+ const spendAfterApproved = getBucketApprovedSpendCents(bucketRecord.id, quarter.id);
+ expect(spendAfterApproved - spendBefore).toBe(orderTotalCents(1, 1000));
+
+ db.update(order).set({ status: "ordered" }).where(eq(order.id, approved.id)).run();
+ const spendAfterOrdered = getBucketApprovedSpendCents(bucketRecord.id, quarter.id);
+ expect(spendAfterOrdered).toBe(spendAfterApproved);
+ });
+});
+
+describe("restoreGiftFundForDeletion", () => {
+ it("refunds gift fund when an approved order is deleted", () => {
+ const requester = db.select().from(user).limit(1).get();
+ if (!requester) return;
+
+ db.update(giftFund)
+ .set({ currentValueCents: 20_000 })
+ .where(eq(giftFund.id, GIFT_FUND_ID))
+ .run();
+
+ const giftOrder = db
+ .insert(order)
+ .values({
+ userId: requester.id,
+ fundType: "Gift",
+ vendor: "Test Vendor",
+ link: "https://example.com/gift",
+ itemName: `${TEST_ITEM_PREFIX}gift-refund`,
+ quantity: 1,
+ unitCostCents: 5000,
+ notes: "Test refund",
+ status: "approved",
+ })
+ .returning()
+ .get();
+
+ const total = orderTotalCents(1, 5000);
+ deductGiftFundForApproval(giftOrder.id, total, requester.id);
+
+ const afterDeduction = db
+ .select()
+ .from(giftFund)
+ .where(eq(giftFund.id, GIFT_FUND_ID))
+ .get()!.currentValueCents;
+ expect(afterDeduction).toBe(20_000 - total);
+
+ restoreGiftFundForDeletion(giftOrder.id, total, requester.id);
+ const afterRefund = db.select().from(giftFund).where(eq(giftFund.id, GIFT_FUND_ID)).get()!
+ .currentValueCents;
+ expect(afterRefund).toBe(20_000);
+
+ db.update(giftFund)
+ .set({ currentValueCents: 0 })
+ .where(eq(giftFund.id, GIFT_FUND_ID))
+ .run();
+ });
+});
+
+describe("markApprovedOrdersAsOrdered", () => {
+ it("moves approved orders to ordered and records history", () => {
+ const requester = db.select().from(user).limit(1).get();
+ const quarter = getActiveQuarter();
+ const bucketRecord = db
+ .select()
+ .from(stfBucket)
+ .where(and(eq(stfBucket.isActive, true)))
+ .limit(1)
+ .get();
+ if (!requester || !quarter || !bucketRecord) return;
+
+ const created = db
+ .insert(order)
+ .values({
+ userId: requester.id,
+ fundType: "STF",
+ stfBucketId: bucketRecord.id,
+ quarterId: quarter.id,
+ vendor: "Test Vendor",
+ link: "https://example.com/mark-ordered",
+ itemName: `${TEST_ITEM_PREFIX}mark-ordered`,
+ partNumber: "TEST-002",
+ quantity: 1,
+ unitCostCents: 2000,
+ status: "approved",
+ })
+ .returning()
+ .get();
+
+ const movedCount = markApprovedOrdersAsOrdered(requester.id);
+ expect(movedCount).toBeGreaterThanOrEqual(1);
+
+ const updated = db.select().from(order).where(eq(order.id, created.id)).get();
+ expect(updated?.status).toBe("ordered");
+
+ const history = db
+ .select()
+ .from(orderHistory)
+ .where(eq(orderHistory.orderId, created.id))
+ .all();
+ expect(history.some((h) => h.fromStatus === "approved" && h.toStatus === "ordered")).toBe(
+ true
+ );
+ });
+
+ it("returns zero when there are no approved orders", () => {
+ const requester = db.select().from(user).limit(1).get();
+ if (!requester) return;
+
+ const previouslyApproved = db
+ .select()
+ .from(order)
+ .where(eq(order.status, "approved"))
+ .all();
+ for (const existing of previouslyApproved) {
+ db.update(order).set({ status: "ordered" }).where(eq(order.id, existing.id)).run();
+ }
+
+ try {
+ expect(markApprovedOrdersAsOrdered(requester.id)).toBe(0);
+ } finally {
+ for (const existing of previouslyApproved) {
+ db.update(order).set({ status: "approved" }).where(eq(order.id, existing.id)).run();
+ }
+ }
+ });
+
+ it("moves only the specified approved orders", () => {
+ const requester = db.select().from(user).limit(1).get();
+ const quarter = getActiveQuarter();
+ const bucketRecord = db
+ .select()
+ .from(stfBucket)
+ .where(and(eq(stfBucket.isActive, true)))
+ .limit(1)
+ .get();
+ if (!requester || !quarter || !bucketRecord) return;
+
+ const first = db
+ .insert(order)
+ .values({
+ userId: requester.id,
+ fundType: "STF",
+ stfBucketId: bucketRecord.id,
+ quarterId: quarter.id,
+ vendor: "Test Vendor",
+ link: "https://example.com/select-1",
+ itemName: `${TEST_ITEM_PREFIX}select-one`,
+ partNumber: "TEST-010",
+ quantity: 1,
+ unitCostCents: 1000,
+ status: "approved",
+ })
+ .returning()
+ .get();
+
+ const second = db
+ .insert(order)
+ .values({
+ userId: requester.id,
+ fundType: "STF",
+ stfBucketId: bucketRecord.id,
+ quarterId: quarter.id,
+ vendor: "Test Vendor",
+ link: "https://example.com/select-2",
+ itemName: `${TEST_ITEM_PREFIX}select-two`,
+ partNumber: "TEST-011",
+ quantity: 1,
+ unitCostCents: 2000,
+ status: "approved",
+ })
+ .returning()
+ .get();
+
+ expect(markApprovedOrdersAsOrdered(requester.id, [first.id])).toBe(1);
+
+ expect(db.select().from(order).where(eq(order.id, first.id)).get()?.status).toBe("ordered");
+ expect(db.select().from(order).where(eq(order.id, second.id)).get()?.status).toBe(
+ "approved"
+ );
});
});
+
+function computeExpectedTotal(
+ quantity: number,
+ unitCostCents: number,
+ taxPercentBps: number,
+ shippingPercentBps: number
+) {
+ const subtotal = quantity * unitCostCents;
+ const tax = Math.round((subtotal * taxPercentBps) / 10_000);
+ const shipping = Math.round((subtotal * shippingPercentBps) / 10_000);
+ return subtotal + tax + shipping;
+}
diff --git a/src/lib/finance/finance.ts b/src/lib/finance/finance.ts
index 9bbd17d..549f204 100644
--- a/src/lib/finance/finance.ts
+++ b/src/lib/finance/finance.ts
@@ -1,20 +1,69 @@
-import { and, eq, sql } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { sendEmail } from "@/lib/integrations/email";
import { db } from "@/lib/db";
import {
+ financeSettings,
giftFund,
giftFundLog,
order,
+ orderHistory,
stfBucket,
stfQuarter,
type FundType,
} from "@/lib/db/schema";
+import {
+ computeOrderTotalCents,
+ DEFAULT_ORDER_PRICING,
+ type OrderPricingSettings,
+} from "@/lib/finance/order-pricing";
export const GIFT_FUND_ID = 1;
+export const FINANCE_SETTINGS_ID = 1;
export function orderTotalCents(quantity: number, unitCostCents: number): number {
- return quantity * unitCostCents;
+ return computeOrderTotalCents(quantity, unitCostCents, getOrderPricingSettings());
+}
+
+export function getOrderPricingSettings(): OrderPricingSettings {
+ const row = db
+ .select()
+ .from(financeSettings)
+ .where(eq(financeSettings.id, FINANCE_SETTINGS_ID))
+ .get();
+ if (!row) return DEFAULT_ORDER_PRICING;
+ return {
+ taxPercentBps: row.taxPercentBps,
+ shippingPercentBps: row.shippingPercentBps,
+ };
+}
+
+export function ensureFinanceSettingsRow() {
+ const existing = db
+ .select()
+ .from(financeSettings)
+ .where(eq(financeSettings.id, FINANCE_SETTINGS_ID))
+ .get();
+ if (!existing) {
+ db.insert(financeSettings)
+ .values({
+ id: FINANCE_SETTINGS_ID,
+ taxPercentBps: DEFAULT_ORDER_PRICING.taxPercentBps,
+ shippingPercentBps: DEFAULT_ORDER_PRICING.shippingPercentBps,
+ })
+ .run();
+ }
+}
+
+export function updateOrderPricingSettings(settings: OrderPricingSettings) {
+ ensureFinanceSettingsRow();
+ db.update(financeSettings)
+ .set({
+ taxPercentBps: settings.taxPercentBps,
+ shippingPercentBps: settings.shippingPercentBps,
+ })
+ .where(eq(financeSettings.id, FINANCE_SETTINGS_ID))
+ .run();
}
export function getActiveQuarter() {
@@ -27,20 +76,26 @@ export function getGiftFundValueCents(): number {
}
export function getBucketApprovedSpendCents(bucketId: number, quarterId: number): number {
- const row = db
+ const settings = getOrderPricingSettings();
+ const rows = db
.select({
- total: sql`coalesce(sum(${order.quantity} * ${order.unitCostCents}), 0)`,
+ quantity: order.quantity,
+ unitCostCents: order.unitCostCents,
})
.from(order)
.where(
and(
eq(order.stfBucketId, bucketId),
eq(order.quarterId, quarterId),
- eq(order.status, "approved")
+ inArray(order.status, ["approved", "ordered"])
)
)
- .get();
- return row?.total ?? 0;
+ .all();
+
+ return rows.reduce(
+ (sum, row) => sum + computeOrderTotalCents(row.quantity, row.unitCostCents, settings),
+ 0
+ );
}
export function getBucketRemainingCents(bucketId: number): number | null {
@@ -153,6 +208,54 @@ export function deductGiftFundForApproval(
.run();
}
+export function restoreGiftFundForDeletion(
+ orderId: number,
+ totalCostCents: number,
+ changedBy: string | null
+) {
+ const current = getGiftFundValueCents();
+ const next = current + totalCostCents;
+
+ db.update(giftFund).set({ currentValueCents: next }).where(eq(giftFund.id, GIFT_FUND_ID)).run();
+
+ db.insert(giftFundLog)
+ .values({
+ changedBy,
+ changeType: "order_deleted",
+ previousValueCents: current,
+ newValueCents: next,
+ orderId,
+ note: "Refund for deleted approved order",
+ })
+ .run();
+}
+
+export function markApprovedOrdersAsOrdered(changedBy: string, orderIds?: number[]): number {
+ const approvedOrders =
+ orderIds && orderIds.length > 0
+ ? db
+ .select()
+ .from(order)
+ .where(and(eq(order.status, "approved"), inArray(order.id, orderIds)))
+ .all()
+ : db.select().from(order).where(eq(order.status, "approved")).all();
+
+ for (const existing of approvedOrders) {
+ db.update(order).set({ status: "ordered" }).where(eq(order.id, existing.id)).run();
+
+ db.insert(orderHistory)
+ .values({
+ orderId: existing.id,
+ fromStatus: "approved",
+ toStatus: "ordered",
+ changedBy,
+ note: "Marked as ordered",
+ })
+ .run();
+ }
+ return approvedOrders.length;
+}
+
export function adjustGiftFund(
newValueCents: number,
changedBy: string,
diff --git a/src/lib/finance/order-export.test.ts b/src/lib/finance/order-export.test.ts
index 41211c0..edf0413 100644
--- a/src/lib/finance/order-export.test.ts
+++ b/src/lib/finance/order-export.test.ts
@@ -7,10 +7,9 @@ import {
formatApprovedStfOrders,
formatApprovedGiftOrders,
STF_PRICE_FLUX,
- STF_TAX_RATE,
- STF_SHIPPING_RATE,
type OrderExportRow,
} from "./order-export";
+import { DEFAULT_ORDER_PRICING } from "./order-pricing";
const BASE_STF: OrderExportRow = {
itemName: "Motor Controller",
@@ -42,13 +41,12 @@ const BASE_GIFT: OrderExportRow = {
describe("stfOrderCalculations", () => {
it("applies flux, tax, and shipping multipliers correctly", () => {
- // qty=2, unitCostCents=5000 → unitCost=$50
- const calc = stfOrderCalculations(2, 5000);
+ const calc = stfOrderCalculations(2, 5000, DEFAULT_ORDER_PRICING);
expect(calc.unitCost).toBe(50);
expect(calc.unitCostFlux).toBeCloseTo(50 * STF_PRICE_FLUX);
expect(calc.preTaxTotal).toBeCloseTo(2 * 50 * STF_PRICE_FLUX);
- expect(calc.tax).toBeCloseTo(calc.preTaxTotal * STF_TAX_RATE);
- expect(calc.shipping).toBeCloseTo(calc.preTaxTotal * STF_SHIPPING_RATE);
+ expect(calc.tax).toBeCloseTo(calc.preTaxTotal * 0.11);
+ expect(calc.shipping).toBeCloseTo(calc.preTaxTotal * 0.2);
expect(calc.total).toBeCloseTo(calc.preTaxTotal + calc.tax + calc.shipping);
});
@@ -70,6 +68,15 @@ describe("stfOrderCalculations", () => {
const calc = stfOrderCalculations(3, 1000);
expect(calc.total).toBeCloseTo(calc.preTaxTotal + calc.tax + calc.shipping, 10);
});
+
+ it("uses custom tax and shipping settings", () => {
+ const calc = stfOrderCalculations(2, 5000, {
+ taxPercentBps: 500,
+ shippingPercentBps: 1000,
+ });
+ expect(calc.tax).toBeCloseTo(calc.preTaxTotal * 0.05);
+ expect(calc.shipping).toBeCloseTo(calc.preTaxTotal * 0.1);
+ });
});
describe("formatStfOrderRow", () => {
@@ -135,6 +142,7 @@ describe("formatOrderForExcel", () => {
it("returns null for non-approved orders", () => {
expect(formatOrderForExcel({ ...BASE_STF, status: "pending" })).toBeNull();
expect(formatOrderForExcel({ ...BASE_STF, status: "denied" })).toBeNull();
+ expect(formatOrderForExcel({ ...BASE_STF, status: "ordered" })).toBeNull();
});
it("returns an STF row for an approved STF order", () => {
@@ -154,6 +162,7 @@ describe("formatApprovedStfOrders", () => {
it("returns empty string when there are no approved STF orders", () => {
expect(formatApprovedStfOrders([])).toBe("");
expect(formatApprovedStfOrders([{ ...BASE_STF, status: "pending" }])).toBe("");
+ expect(formatApprovedStfOrders([{ ...BASE_STF, status: "ordered" }])).toBe("");
});
it("returns one row per approved STF order joined by newline", () => {
@@ -172,6 +181,7 @@ describe("formatApprovedGiftOrders", () => {
it("returns empty string when there are no approved Gift orders", () => {
expect(formatApprovedGiftOrders([])).toBe("");
expect(formatApprovedGiftOrders([{ ...BASE_GIFT, status: "denied" }])).toBe("");
+ expect(formatApprovedGiftOrders([{ ...BASE_GIFT, status: "ordered" }])).toBe("");
});
it("returns one row per approved Gift order", () => {
diff --git a/src/lib/finance/order-export.ts b/src/lib/finance/order-export.ts
index 762083a..850f43a 100644
--- a/src/lib/finance/order-export.ts
+++ b/src/lib/finance/order-export.ts
@@ -1,8 +1,7 @@
import type { FundType, OrderStatus } from "@/lib/db/schema";
+import { DEFAULT_ORDER_PRICING, type OrderPricingSettings } from "@/lib/finance/order-pricing";
export const STF_PRICE_FLUX = 1.2;
-export const STF_TAX_RATE = 0.11;
-export const STF_SHIPPING_RATE = 0.2;
export type OrderExportRow = {
itemName: string;
@@ -67,20 +66,31 @@ function joinRow(cells: (string | number)[]): string {
return cells.map((cell) => escapeTsvCell(String(cell))).join("\t");
}
-export function stfOrderCalculations(quantity: number, unitCostCents: number) {
+export function stfOrderCalculations(
+ quantity: number,
+ unitCostCents: number,
+ settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
+) {
const unitCost = unitCostCents / 100;
const unitCostFlux = unitCost * STF_PRICE_FLUX;
const preTaxTotal = quantity * unitCostFlux;
- const tax = preTaxTotal * STF_TAX_RATE;
- const shipping = preTaxTotal * STF_SHIPPING_RATE;
+ const taxRate = settings.taxPercentBps / 10_000;
+ const shippingRate = settings.shippingPercentBps / 10_000;
+ const tax = preTaxTotal * taxRate;
+ const shipping = preTaxTotal * shippingRate;
const total = preTaxTotal + tax + shipping;
return { unitCost, unitCostFlux, preTaxTotal, tax, shipping, total };
}
-export function formatStfOrderRow(order: OrderExportRow, includeHeader = false): string {
+export function formatStfOrderRow(
+ order: OrderExportRow,
+ includeHeader = false,
+ settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
+): string {
const { unitCost, unitCostFlux, preTaxTotal, tax, shipping, total } = stfOrderCalculations(
order.quantity,
- order.unitCostCents
+ order.unitCostCents,
+ settings
);
const row = joinRow([
@@ -118,17 +128,24 @@ export function formatGiftOrderRow(order: OrderExportRow, includeHeader = false)
return `${joinRow([...GIFT_EXCEL_HEADERS])}\n${row}`;
}
-export function formatOrderForExcel(order: OrderExportRow, includeHeader = false): string | null {
+export function formatOrderForExcel(
+ order: OrderExportRow,
+ includeHeader = false,
+ settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
+): string | null {
if (order.status !== "approved") return null;
- if (order.fundType === "STF") return formatStfOrderRow(order, includeHeader);
+ if (order.fundType === "STF") return formatStfOrderRow(order, includeHeader, settings);
if (order.fundType === "Gift") return formatGiftOrderRow(order, includeHeader);
return null;
}
-export function formatApprovedStfOrders(orders: OrderExportRow[]): string {
+export function formatApprovedStfOrders(
+ orders: OrderExportRow[],
+ settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
+): string {
const approved = orders.filter((o) => o.status === "approved" && o.fundType === "STF");
if (approved.length === 0) return "";
- return approved.map((o) => formatStfOrderRow(o)).join("\n");
+ return approved.map((o) => formatStfOrderRow(o, false, settings)).join("\n");
}
export function formatApprovedGiftOrders(orders: OrderExportRow[]): string {
diff --git a/src/lib/finance/order-pricing.test.ts b/src/lib/finance/order-pricing.test.ts
new file mode 100644
index 0000000..ac0f118
--- /dev/null
+++ b/src/lib/finance/order-pricing.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ computeOrderTotalCents,
+ DEFAULT_ORDER_PRICING,
+ displayPercentToBps,
+ percentBpsToDisplay,
+} from "./order-pricing";
+
+describe("computeOrderTotalCents", () => {
+ it("adds tax and shipping percentages to the subtotal", () => {
+ expect(computeOrderTotalCents(2, 5000, DEFAULT_ORDER_PRICING)).toBe(13_100);
+ });
+
+ it("returns subtotal when tax and shipping are zero", () => {
+ expect(computeOrderTotalCents(2, 5000, { taxPercentBps: 0, shippingPercentBps: 0 })).toBe(
+ 10_000
+ );
+ });
+
+ it("returns 0 when quantity is 0", () => {
+ expect(computeOrderTotalCents(0, 5000)).toBe(0);
+ });
+});
+
+describe("percent conversions", () => {
+ it("converts between display percent and basis points", () => {
+ expect(displayPercentToBps(11)).toBe(1100);
+ expect(percentBpsToDisplay(1100)).toBe(11);
+ });
+
+ it("supports zero and full-percent basis point values", () => {
+ expect(displayPercentToBps(0)).toBe(0);
+ expect(displayPercentToBps(100)).toBe(10_000);
+ });
+});
diff --git a/src/lib/finance/order-pricing.ts b/src/lib/finance/order-pricing.ts
new file mode 100644
index 0000000..0c800c4
--- /dev/null
+++ b/src/lib/finance/order-pricing.ts
@@ -0,0 +1,35 @@
+export const DEFAULT_TAX_PERCENT_BPS = 1100;
+export const DEFAULT_SHIPPING_PERCENT_BPS = 2000;
+
+export type OrderPricingSettings = {
+ taxPercentBps: number;
+ shippingPercentBps: number;
+};
+
+export const DEFAULT_ORDER_PRICING: OrderPricingSettings = {
+ taxPercentBps: DEFAULT_TAX_PERCENT_BPS,
+ shippingPercentBps: DEFAULT_SHIPPING_PERCENT_BPS,
+};
+
+export function percentBpsToDisplay(percentBps: number): number {
+ return percentBps / 100;
+}
+
+export function displayPercentToBps(percent: number): number {
+ return Math.round(percent * 100);
+}
+
+export function orderSubtotalCents(quantity: number, unitCostCents: number): number {
+ return quantity * unitCostCents;
+}
+
+export function computeOrderTotalCents(
+ quantity: number,
+ unitCostCents: number,
+ settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
+): number {
+ const subtotal = orderSubtotalCents(quantity, unitCostCents);
+ const tax = Math.round((subtotal * settings.taxPercentBps) / 10_000);
+ const shipping = Math.round((subtotal * settings.shippingPercentBps) / 10_000);
+ return subtotal + tax + shipping;
+}
diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts
index 133d457..338d3fb 100644
--- a/src/lib/validation.test.ts
+++ b/src/lib/validation.test.ts
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
orderInputSchema,
giftFundAdjustSchema,
+ financeSettingsUpdateSchema,
+ markOrderedSchema,
vaultEntrySchema,
whitelistRequestSchema,
stfBucketInputSchema,
@@ -105,6 +107,65 @@ describe("giftFundAdjustSchema", () => {
});
});
+describe("financeSettingsUpdateSchema", () => {
+ it("accepts valid tax and shipping percentages", () => {
+ expect(() =>
+ financeSettingsUpdateSchema.parse({ taxPercent: 11, shippingPercent: 20 })
+ ).not.toThrow();
+ });
+
+ it("accepts zero for tax and shipping", () => {
+ expect(() =>
+ financeSettingsUpdateSchema.parse({ taxPercent: 0, shippingPercent: 0 })
+ ).not.toThrow();
+ });
+
+ it("rejects negative tax", () => {
+ const result = financeSettingsUpdateSchema.safeParse({
+ taxPercent: -1,
+ shippingPercent: 10,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects negative shipping", () => {
+ const result = financeSettingsUpdateSchema.safeParse({
+ taxPercent: 10,
+ shippingPercent: -0.01,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects percentages above 100", () => {
+ expect(
+ financeSettingsUpdateSchema.safeParse({ taxPercent: 101, shippingPercent: 10 }).success
+ ).toBe(false);
+ expect(
+ financeSettingsUpdateSchema.safeParse({ taxPercent: 10, shippingPercent: 100.01 })
+ .success
+ ).toBe(false);
+ });
+});
+
+describe("markOrderedSchema", () => {
+ it("accepts an empty body to move all approved orders", () => {
+ expect(() => markOrderedSchema.parse({})).not.toThrow();
+ });
+
+ it("accepts a list of order ids", () => {
+ expect(() => markOrderedSchema.parse({ orderIds: [1, 2, 3] })).not.toThrow();
+ });
+
+ it("rejects an empty orderIds array", () => {
+ const result = markOrderedSchema.safeParse({ orderIds: [] });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects invalid order ids", () => {
+ expect(markOrderedSchema.safeParse({ orderIds: [0, -1] }).success).toBe(false);
+ });
+});
+
describe("vaultEntrySchema (discriminated union)", () => {
it("accepts a valid login entry", () => {
expect(() =>
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index c4d9414..b8930bd 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -54,6 +54,10 @@ export const ORDER_ACTION_STATUS = {
deny: "denied",
} as const;
+export const markOrderedSchema = z.object({
+ orderIds: z.array(z.coerce.number().int().positive()).min(1).optional(),
+});
+
// Finance ------------------------------------------------------------------
export const stfBucketInputSchema = z.object({
@@ -81,6 +85,11 @@ export const quarterResetSchema = z.object({
newQuarterName: z.string().trim().min(1).max(100),
});
+export const financeSettingsUpdateSchema = z.object({
+ taxPercent: z.coerce.number().min(0, "Tax cannot be negative").max(100),
+ shippingPercent: z.coerce.number().min(0, "Shipping cannot be negative").max(100),
+});
+
// API keys -----------------------------------------------------------------
export const createApiKeySchema = z.object({
diff --git a/vitest.config.ts b/vitest.config.ts
index bcc7efc..ce810ce 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,5 +6,9 @@ export default defineConfig({
},
test: {
environment: "node",
+ env: {
+ DATABASE_PATH: "db/test.db",
+ },
+ globalSetup: ["./vitest.global-setup.ts"],
},
});
diff --git a/vitest.global-setup.ts b/vitest.global-setup.ts
new file mode 100644
index 0000000..d0eb1b5
--- /dev/null
+++ b/vitest.global-setup.ts
@@ -0,0 +1,21 @@
+import { execSync } from "node:child_process";
+import { existsSync, mkdirSync, unlinkSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+
+const TEST_DB_PATH = resolve(process.cwd(), "db/test.db");
+
+function removeDbFiles(path: string) {
+ for (const file of [path, `${path}-wal`, `${path}-shm`]) {
+ if (existsSync(file)) unlinkSync(file);
+ }
+}
+
+export default function setup() {
+ mkdirSync(dirname(TEST_DB_PATH), { recursive: true });
+ removeDbFiles(TEST_DB_PATH);
+
+ execSync("pnpm db:migrate", {
+ stdio: "inherit",
+ env: { ...process.env, DATABASE_PATH: TEST_DB_PATH },
+ });
+}