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. +

+
+
+
+ + setTaxPercent(e.target.value)} + /> +
+
+ + setShippingPercent(e.target.value)} + /> +
+ +
+
+

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) && (
+
)} - {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={ + + } + /> + ) : 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()}> + +
) : 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 (
-
- - - - - -
+ {showFilters ? ( +
+ + + + + +
+ ) : 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 }, + }); +}