diff --git a/_maps/RandomRuins/SpaceRuins/hellfactory.dmm b/_maps/RandomRuins/SpaceRuins/hellfactory.dmm
index ec709f93ef0..df0146b7db5 100644
--- a/_maps/RandomRuins/SpaceRuins/hellfactory.dmm
+++ b/_maps/RandomRuins/SpaceRuins/hellfactory.dmm
@@ -72,10 +72,6 @@
/obj/item/storage/cans/sixbeer,
/turf/open/floor/iron/grimy,
/area/ruin/space/has_grav/hellfactoryoffice)
-"am" = (
-/obj/machinery/paystand,
-/turf/open/floor/iron/grimy,
-/area/ruin/space/has_grav/hellfactoryoffice)
"an" = (
/obj/effect/decal/remains/human,
/obj/effect/decal/cleanable/blood/old,
@@ -1367,7 +1363,7 @@ aa
aa
aa
ah
-am
+ao
ao
aH
ao
diff --git a/_maps/map_files/Deltastation/DeltaStation2.dmm b/_maps/map_files/Deltastation/DeltaStation2.dmm
index 3c1144cd81f..9e2e15945dc 100644
--- a/_maps/map_files/Deltastation/DeltaStation2.dmm
+++ b/_maps/map_files/Deltastation/DeltaStation2.dmm
@@ -55557,7 +55557,6 @@
"kbN" = (
/obj/structure/table,
/obj/item/stack/sheet/iron/five,
-/obj/item/circuitboard/machine/paystand,
/obj/effect/turf_decal/tile/brown{
dir = 4
},
@@ -82182,7 +82181,6 @@
/area/cargo/office)
"qPc" = (
/obj/structure/table/reinforced,
-/obj/item/circuitboard/machine/paystand,
/obj/item/stack/cable_coil/five,
/obj/machinery/airalarm/directional/north,
/turf/open/floor/plating,
@@ -97226,7 +97224,6 @@
dir = 4
},
/obj/structure/table,
-/obj/item/circuitboard/machine/paystand,
/obj/item/wrench,
/obj/item/screwdriver,
/obj/effect/turf_decal/tile/neutral{
diff --git a/_maps/map_files/IceBoxStation/IceBoxStation.dmm b/_maps/map_files/IceBoxStation/IceBoxStation.dmm
index e349126b7ad..b458bfc8049 100644
--- a/_maps/map_files/IceBoxStation/IceBoxStation.dmm
+++ b/_maps/map_files/IceBoxStation/IceBoxStation.dmm
@@ -21575,7 +21575,6 @@
/obj/structure/table,
/obj/machinery/firealarm/directional/north,
/obj/item/stack/sheet/iron/five,
-/obj/item/circuitboard/machine/paystand,
/obj/item/stack/cable_coil/five,
/obj/effect/turf_decal/tile/brown{
dir = 4
diff --git a/_maps/map_files/KiloStation/KiloStation.dmm b/_maps/map_files/KiloStation/KiloStation.dmm
index ea645f26a51..ad7a924fd83 100644
--- a/_maps/map_files/KiloStation/KiloStation.dmm
+++ b/_maps/map_files/KiloStation/KiloStation.dmm
@@ -15451,7 +15451,6 @@
},
/obj/effect/decal/cleanable/dirt,
/obj/item/folder/red,
-/obj/item/circuitboard/machine/paystand,
/obj/structure/spider/stickyweb,
/turf/open/floor/iron/dark,
/area/maintenance/port)
@@ -61919,7 +61918,6 @@
/obj/machinery/firealarm/directional/west,
/obj/machinery/newscaster/directional/south,
/obj/item/clipboard,
-/obj/item/circuitboard/machine/paystand,
/turf/open/floor/iron/dark,
/area/commons/vacant_room/commissary)
"qeZ" = (
diff --git a/_maps/map_files/MetaStation/MetaStation.dmm b/_maps/map_files/MetaStation/MetaStation.dmm
index abcc33c180d..ec538ac1c89 100644
--- a/_maps/map_files/MetaStation/MetaStation.dmm
+++ b/_maps/map_files/MetaStation/MetaStation.dmm
@@ -38480,7 +38480,6 @@
/obj/item/radio/intercom/directional/east{
pixel_y = 8
},
-/obj/item/circuitboard/machine/paystand,
/obj/item/stack/cable_coil/five,
/obj/effect/turf_decal/tile/brown{
dir = 4
diff --git a/_maps/map_files/tramstation/tramstation.dmm b/_maps/map_files/tramstation/tramstation.dmm
index b188a42948c..d366b740acd 100644
--- a/_maps/map_files/tramstation/tramstation.dmm
+++ b/_maps/map_files/tramstation/tramstation.dmm
@@ -61970,7 +61970,6 @@
/obj/effect/turf_decal/trimline/neutral/filled/line{
dir = 4
},
-/obj/item/circuitboard/machine/paystand,
/obj/item/stack/sheet/iron/five,
/obj/item/stack/cable_coil/five,
/turf/open/floor/iron,
diff --git a/code/game/objects/items/cards_ids.dm b/code/game/objects/items/cards_ids.dm
index 93d39e6204b..68ecbf133f3 100644
--- a/code/game/objects/items/cards_ids.dm
+++ b/code/game/objects/items/cards_ids.dm
@@ -7,6 +7,9 @@
/// Fallback time if none of the config entries are set for USE_LOW_LIVING_HOUR_INTERN
#define INTERN_THRESHOLD_FALLBACK_HOURS 15
+/// Max time interval between projecting holopays
+#define HOLOPAY_PROJECTION_INTERVAL 7 SECONDS
+
/* Cards
* Contains:
* DATA CARD
@@ -59,8 +62,28 @@
var/registered_name = null
/// Linked bank account.
var/datum/bank_account/registered_account
- /// Linked paystand.
- var/obj/machinery/paystand/my_store
+
+ /// Linked holopay.
+ var/datum/weakref/holopay_ref
+ /// Cooldown between projecting holopays
+ COOLDOWN_DECLARE(last_holopay_projection)
+ /// List of logos available for holopay customization - via font awesome 5
+ var/static/list/available_logos = list("angry", "ankh", "bacon", "band-aid", "cannabis", "cat", "cocktail", "coins", "comments-dollar",
+ "cross", "cut", "dog", "donate", "dna", "fist-raised", "flask", "glass-cheers", "glass-martini-alt", "hamburger", "hand-holding-usd",
+ "hat-wizard", "head-side-cough-slash", "heart", "heart-broken", "laugh-beam", "leaf", "money-check-alt", "music", "piggy-bank",
+ "pizza-slice", "prescription-bottle-alt", "radiation", "robot", "smile", "skull-crossbones", "smoking", "space-shuttle", "tram",
+ "trash", "user-ninja", "utensils", "wrench")
+ /// Replaces the "pay whatever" functionality with a set amount when non-zero.
+ var/holopay_fee = 0
+ /// The holopay icon chosen by the user
+ var/holopay_logo = "donate"
+ /// Maximum forced fee. It's unlikely for a user to encounter this type of money, much less pay it willingly.
+ var/holopay_max_fee = 5000
+ /// Minimum forced fee for holopay stations. Registers as "pay what you want."
+ var/holopay_min_fee = 0
+ /// The holopay name chosen by the user
+ var/holopay_name = "holographic pay stand"
+
/// Registered owner's age.
var/registered_age = 30
@@ -94,8 +117,8 @@
/obj/item/card/id/Destroy()
if (registered_account)
registered_account.bank_cards -= src
- if (my_store && my_store.my_card == src)
- my_store.my_card = null
+ if (holopay_ref)
+ QDEL_NULL(holopay_ref)
return ..()
/obj/item/card/id/get_id_examine_strings(mob/user)
@@ -383,6 +406,103 @@
user.visible_message(span_notice("[user] shows you: [icon2html(src, viewers(user))] [src.name][minor]."), span_notice("You show \the [src.name][minor]."))
add_fingerprint(user)
+/obj/item/card/id/attack_hand_secondary(mob/user, list/modifiers)
+ . = ..()
+ if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
+ return
+ /// Sanity checks
+ var/obj/structure/holopay/my_store = holopay_ref?.resolve()
+ if(my_store)
+ my_store.dissapate()
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ if(!COOLDOWN_FINISHED(src, last_holopay_projection))
+ balloon_alert(user, "still recharging")
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ if(!registered_account || !registered_account.account_job)
+ balloon_alert(user, "no account")
+ to_chat(user, span_warning("You need a valid bank account to do this."))
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ /// Determines where the holopay will be placed based on tile contents
+ var/turf/projection
+ var/turf/step_ahead = get_step(user, user.dir)
+ var/turf/user_loc = user.loc
+ if(can_proj_holopay(step_ahead))
+ projection = step_ahead
+ else if(can_proj_holopay(user_loc))
+ projection = user_loc
+ if(!projection)
+ balloon_alert(user, "no space")
+ to_chat(user, span_warning("You need to be standing on or near an open tile to do this."))
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ /// Success: Valid tile for holopay placement
+ var/obj/structure/holopay/new_store = new(projection)
+ if(new_store?.assign_card(projection, src))
+ COOLDOWN_START(src, last_holopay_projection, HOLOPAY_PROJECTION_INTERVAL)
+ playsound(projection, "sound/effects/empulse.ogg", 40, TRUE)
+ holopay_ref = WEAKREF(new_store)
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+
+/**
+ * Determines whether a new holopay can be placed on the given turf.
+ * Checks if there are dense contents, too many contents, or another
+ * holopay already exists on the turf.
+ *
+ * Arguments:
+ * * turf/target - The target turf to be checked for dense contents
+ * Returns:
+ * * TRUE if the target is a valid holopay location, FALSE otherwise.
+ */
+/obj/item/card/id/proc/can_proj_holopay(turf/target)
+ if(!isfloorturf(target))
+ return FALSE
+ if(target.density)
+ return FALSE
+ if(length(target.contents) > 5)
+ return FALSE
+ for(var/obj/checked_obj in target.contents)
+ if(checked_obj.density)
+ return FALSE
+ if(istype(checked_obj, /obj/structure/holopay))
+ return FALSE
+ return TRUE
+
+/**
+ * Setter for the shop logo on linked holopays
+ *
+ * Arguments:
+ * * new_logo - The new logo to be set.
+ */
+/obj/item/card/id/proc/set_holopay_logo(new_logo)
+ if(!available_logos.Find(new_logo))
+ CRASH("User input a holopay shop logo that didn't exist.")
+ holopay_logo = new_logo
+
+/**
+ * Setter for changing the force fee on a holopay.
+ *
+ * Arguments:
+ * * new_fee - The new fee to be set.
+ */
+/obj/item/card/id/proc/set_holopay_fee(new_fee)
+ if(!isnum(new_fee))
+ CRASH("User input a non number into the holopay fee field.")
+ if(new_fee < holopay_min_fee || new_fee > holopay_max_fee)
+ CRASH("User input a number outside of the valid range into the holopay fee field.")
+ holopay_fee = new_fee
+
+/**
+ * Setter for changing the holopay name.
+ *
+ * Arguments:
+ * * new_name - The new name to be set.
+ */
+/obj/item/card/id/proc/set_holopay_name(name)
+ if(length(name) < 3 || length(name) > MAX_NAME_LEN)
+ to_chat(usr, span_warning("Must be between 3 - 42 characters."))
+ else
+ holopay_name = html_encode(trim(name, MAX_NAME_LEN))
+
+
/obj/item/card/id/vv_edit_var(var_name, var_value)
. = ..()
if(.)
diff --git a/code/game/objects/items/circuitboards/machine_circuitboards.dm b/code/game/objects/items/circuitboards/machine_circuitboards.dm
index a9e777793f1..cfaefdec9f5 100644
--- a/code/game/objects/items/circuitboards/machine_circuitboards.dm
+++ b/code/game/objects/items/circuitboards/machine_circuitboards.dm
@@ -397,12 +397,6 @@
/obj/item/stock_parts/manipulator = 1)
def_components = list(/obj/item/stack/ore/bluespace_crystal = /obj/item/stack/ore/bluespace_crystal/artificial)
-/obj/item/circuitboard/machine/paystand
- name = "Pay Stand (Machine Board)"
- greyscale_colors = CIRCUIT_COLOR_GENERIC
- build_path = /obj/machinery/paystand
- req_components = list()
-
/obj/item/circuitboard/machine/protolathe
name = "Protolathe (Machine Board)"
greyscale_colors = CIRCUIT_COLOR_GENERIC
diff --git a/code/modules/art/paintings.dm b/code/modules/art/paintings.dm
index b536ece86ef..a4f76b2a225 100644
--- a/code/modules/art/paintings.dm
+++ b/code/modules/art/paintings.dm
@@ -173,7 +173,7 @@
to_chat(user,span_notice("You don't even have a id and you want to be an art patron?"))
return
if(!id_card.registered_account || !id_card.registered_account.account_job)
- to_chat(user,span_notice("No valid non-departamental account found."))
+ to_chat(user,span_notice("No valid non-departmental account found."))
return
var/datum/bank_account/account = id_card.registered_account
if(account.account_balance < painting_metadata.credit_value)
diff --git a/code/modules/economy/holopay.dm b/code/modules/economy/holopay.dm
new file mode 100644
index 00000000000..fd1bd1e4e6b
--- /dev/null
+++ b/code/modules/economy/holopay.dm
@@ -0,0 +1,268 @@
+/obj/structure/holopay
+ name = "holographic pay stand"
+ desc = "an unregistered pay stand"
+ icon = 'icons/obj/economy.dmi'
+ icon_state = "card_scanner"
+ alpha = 150
+ anchored = TRUE
+ armor = list(MELEE = 0, BULLET = 50, LASER = 50, ENERGY = 50, BOMB = 0, BIO = 0, FIRE = 20, ACID = 20)
+ max_integrity = 15
+ layer = FLY_LAYER
+ /// ID linked to the holopay
+ var/datum/weakref/card_ref
+ /// Max range at which the hologram can be projected before it deletes
+ var/max_holo_range = 4
+ /// The holopay shop icon displayed in the UI
+ var/shop_logo = "donate"
+ /// Replaces the "pay whatever" functionality with a set amount when non-zero.
+ var/force_fee = 0
+
+/obj/structure/holopay/examine(mob/user)
+ . = ..()
+ if(force_fee)
+ . += span_boldnotice("This holopay forces a payment of [force_fee] credit\s per swipe instead of a variable amount.")
+
+/obj/structure/holopay/attack_hand(mob/living/user, list/modifiers)
+ . = ..()
+ if(.)
+ return
+ if(!user.combat_mode)
+ ui_interact(user)
+ return .
+ user.do_attack_animation(src, ATTACK_EFFECT_PUNCH)
+ user.changeNext_move(CLICK_CD_MELEE)
+ take_damage(5, BRUTE, MELEE, 1)
+
+/obj/structure/holopay/play_attack_sound(damage_amount, damage_type = BRUTE, damage_flag = 0)
+ switch(damage_type)
+ if(BRUTE)
+ playsound(loc, 'sound/weapons/egloves.ogg', 80, TRUE)
+ if(BURN)
+ playsound(loc, 'sound/weapons/egloves.ogg', 80, TRUE)
+
+/obj/structure/holopay/deconstruct()
+ dissapate()
+ return ..()
+
+/obj/structure/holopay/attackby(obj/item/held_item, mob/holder, params)
+ var/mob/living/user = holder
+ if(!isliving(user))
+ return ..()
+ /// Users can pay with an ID to skip the UI
+ if(istype(held_item, /obj/item/card/id))
+ if(force_fee && tgui_alert(holder, "This holopay has a [force_fee] cr fee. Confirm?", "Holopay Fee", list("Pay", "Cancel")) != "Pay")
+ return TRUE
+ process_payment(user)
+ return TRUE
+ /// Users can also pay by holochip
+ if(istype(held_item, /obj/item/holochip))
+ /// Account checks
+ var/obj/item/holochip/chip = held_item
+ if(!chip.credits)
+ balloon_alert(user, "holochip is empty")
+ to_chat(user, span_warning("There doesn't seem to be any credits here."))
+ return FALSE
+ /// Charges force fee or uses pay what you want
+ var/cash_deposit = force_fee || tgui_input_number(user, "How much? (Max: [chip.credits])", "Patronage", max_value = chip.credits)
+ /// Exit sanity checks
+ if(!cash_deposit)
+ return TRUE
+ if(QDELETED(held_item) || QDELETED(user) || QDELETED(src) || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
+ return FALSE
+ if(!chip.spend(cash_deposit, FALSE))
+ balloon_alert(user, "insufficient credits")
+ to_chat(user, span_warning("You don't have enough credits to pay with this chip."))
+ return FALSE
+ /// Success: Alert buyer
+ alert_buyer(user, cash_deposit)
+ return TRUE
+ /// Throws errors if they try to use space cash
+ if(istype(held_item, /obj/item/stack/spacecash))
+ to_chat(user, "What is this, the 2000s? We only take card here.")
+ return TRUE
+ if(istype(held_item, /obj/item/coin))
+ to_chat(user, "What is this, the 1800s? We only take card here.")
+ return TRUE
+ return ..()
+
+/obj/structure/holopay/ui_interact(mob/user, datum/tgui/ui)
+ . = ..()
+ if(.)
+ return FALSE
+ var/mob/living/interactor = user
+ if(isliving(interactor) && interactor.combat_mode)
+ return FALSE
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "HoloPay")
+ ui.open()
+
+/obj/structure/holopay/ui_status(mob/user)
+ . = ..()
+ if(!in_range(user, src) && !isobserver(user))
+ return UI_CLOSE
+
+/obj/structure/holopay/ui_static_data(mob/user)
+ . = list()
+ /// Sanity checks
+ var/obj/item/card/id/linked_card = get_card()
+ .["available_logos"] = linked_card.available_logos
+ .["description"] = desc
+ .["max_fee"] = linked_card.holopay_max_fee
+ .["owner"] = linked_card.registered_account?.account_holder || null
+ .["shop_logo"] = shop_logo
+
+/obj/structure/holopay/ui_data(mob/user)
+ . = list()
+ .["force_fee"] = force_fee
+ .["name"] = name
+ if(!isliving(user))
+ return .
+ var/mob/living/card_holder = user
+ var/obj/item/card/id/id_card = card_holder.get_idcard(TRUE)
+ var/datum/bank_account/account = id_card?.registered_account || null
+ if(account)
+ .["user"] = list()
+ .["user"]["name"] = account.account_holder
+ .["user"]["balance"] = account.account_balance
+
+/obj/structure/holopay/ui_act(action, list/params, datum/tgui/ui)
+ . = ..()
+ if(.)
+ return FALSE
+ var/obj/item/card/id/linked_card = get_card()
+ switch(action)
+ if("done")
+ ui.send_full_update()
+ return TRUE
+ if("fee")
+ linked_card.set_holopay_fee(params["amount"])
+ force_fee = linked_card.holopay_fee
+ if("logo")
+ linked_card.set_holopay_logo(params["logo"])
+ shop_logo = linked_card.holopay_logo
+ if("pay")
+ ui.close()
+ process_payment(usr)
+ return TRUE
+ if("rename")
+ linked_card.set_holopay_name(params["name"])
+ name = linked_card.holopay_name
+ return FALSE
+
+/**
+ * Links the source card to the holopay. Begins checking if its in range.
+ *
+ * Parameters:
+ * * turf/target - The tile to project the holopay onto
+ * * obj/item/card/id/card - The card to link to the holopay
+ * Returns:
+ * * TRUE - the card was linked
+ */
+/obj/structure/holopay/proc/assign_card(turf/target, obj/item/card/id/card)
+ card_ref = WEAKREF(card)
+ desc = "Pays directly into [card.registered_account.account_holder]'s bank account."
+ force_fee = card.holopay_fee
+ shop_logo = card.holopay_logo
+ name = card.holopay_name
+ add_atom_colour("#77abff", FIXED_COLOUR_PRIORITY)
+ set_light(2)
+ visible_message(span_notice("A holographic pay stand appears."))
+ /// Start checking if the source projection is in range
+ RegisterSignal(card, COMSIG_MOVABLE_MOVED, .proc/check_operation)
+ if(card.loc)
+ RegisterSignal(card.loc, COMSIG_MOVABLE_MOVED, .proc/check_operation)
+ return TRUE
+
+/**
+ * A periodic check to see if the projecting card is nearby.
+ * Deletes the holopay if true.
+ */
+/obj/structure/holopay/proc/check_operation()
+ SIGNAL_HANDLER
+ var/obj/item/card/id/linked_card = get_card()
+ if(!IN_GIVEN_RANGE(src, linked_card, max_holo_range) || !IN_GIVEN_RANGE(src, linked_card.loc, max_holo_range))
+ dissapate()
+
+/**
+ * Creates holopay vanishing effects.
+ * Deletes the holopay thereafter.
+ */
+/obj/structure/holopay/proc/dissapate()
+ var/obj/item/card/id/linked_card = get_card()
+ playsound(loc, "sound/effects/empulse.ogg", 40, TRUE)
+ visible_message(span_notice("The pay stand vanishes."))
+ QDEL_NULL(linked_card.holopay_ref)
+
+/**
+ * Checks that the card is still linked.
+ * Deletes the holopay if not.
+ *
+ * Returns:
+ * * /obj/item/card/id/card - The card that is linked to the holopay
+ */
+/obj/structure/holopay/proc/get_card()
+ var/obj/item/card/id/linked_card = card_ref?.resolve()
+ if(!linked_card || !istype(linked_card, /obj/item/card/id))
+ stack_trace("Could not link a holopay to a valid card.")
+ qdel(src)
+ return linked_card
+
+/**
+ * Initiates a transaction between accounts.
+ *
+ * Parameters:
+ * * mob/living/user - The user who initiated the transaction.
+ * Returns:
+ * * TRUE - transaction was successful
+ */
+/obj/structure/holopay/proc/process_payment(mob/living/user)
+ // Preliminary sanity checks
+ var/obj/item/card/id/linked_card = get_card()
+ /// Account checks
+ var/obj/item/card/id/id_card
+ id_card = user.get_idcard(TRUE)
+ if(!id_card || !id_card.registered_account || !id_card.registered_account.account_job)
+ balloon_alert(user, "invalid account")
+ to_chat(user, span_warning("You don't have a valid account."))
+ return FALSE
+ var/datum/bank_account/payee = id_card.registered_account
+ if(payee == linked_card.registered_account)
+ balloon_alert(user, "invalid transaction")
+ to_chat(user, span_warning("You can't pay yourself."))
+ return FALSE
+ /// If the user has enough money, ask them the amount or charge the force fee
+ var/amount = force_fee || tgui_input_number(user, "How much? (Max: [payee.account_balance])", "Patronage", max_value = payee.account_balance)
+ /// Exit checks in case the user cancelled or entered an invalid amount
+ if(!amount || QDELETED(user) || QDELETED(src) || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
+ return FALSE
+ if(!payee.adjust_money(-amount))
+ balloon_alert(user, "insufficient credits")
+ to_chat(user, span_warning("You don't have the money to pay for this."))
+ return FALSE
+ /// Success: Alert the buyer
+ alert_buyer(user, amount)
+ return TRUE
+
+/**
+ * Alerts the owner of the transaction.
+ *
+ * Parameters:
+ * * payee - The user who initiated the transaction.
+ * * amount - The amount of money that was paid.
+ * Returns:
+ * * TRUE - alert was successful.
+ */
+/obj/structure/holopay/proc/alert_buyer(payee, amount)
+ /// Sanity checks
+ var/obj/item/card/id/linked_card = get_card()
+ /// Pay the owner
+ linked_card.registered_account.adjust_money(amount)
+ /// Make alerts
+ linked_card.registered_account.bank_card_talk("[payee] has deposited [amount] cr at your holographic pay stand.")
+ say("Thank you for your patronage, [payee]!")
+ playsound(src, 'sound/effects/cashregister.ogg', 20, TRUE)
+ /// Log the event
+ log_econ("[amount] credits were transferred from [payee]'s transaction to [linked_card.registered_account.account_holder]")
+ SSblackbox.record_feedback("amount", "credits_transferred", amount)
+ return TRUE
diff --git a/code/modules/economy/pay_stand.dm b/code/modules/economy/pay_stand.dm
deleted file mode 100644
index fc84bab90dc..00000000000
--- a/code/modules/economy/pay_stand.dm
+++ /dev/null
@@ -1,152 +0,0 @@
-/obj/machinery/paystand
- name = "unregistered pay stand"
- desc = "See title."
- icon = 'icons/obj/economy.dmi'
- icon_state = "card_scanner"
- density = TRUE
- anchored = TRUE
- var/locked = FALSE
- var/obj/item/card/id/my_card
- var/obj/item/assembly/signaler/signaler //attached signaler, let people attach signalers that get activated if the user's transaction limit is achieved.
- var/signaler_threshold = 0 //signaler threshold amount
- var/amount_deposited = 0 //keep track of the amount deposited over time so you can pay multiple times to reach the signaler threshold
- var/force_fee = 0 //replaces the "pay whatever" functionality with a set amount when non-zero.
-
-/obj/machinery/paystand/attackby(obj/item/W, mob/user, params)
- if(istype(W, /obj/item/card/id))
- if(W == my_card)
- var/list/items = list(
- "Rename" = image(icon = 'icons/obj/economy.dmi', icon_state = "name"),
- "Set the fee" = image(icon = 'icons/obj/economy.dmi', icon_state = "fee")
- )
- var/choice = show_radial_menu(user, src, items, null, require_near = TRUE, tooltips = TRUE)
- if(choice == "Rename")
- var/rename_msg = tgui_input_text(user, "Rename the Paystand", "Paystand Name", name)
- if(!rename_msg || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
- return
- name = rename_msg
- return
- else if(choice == "Set the fee")
- var/force_fee_input = tgui_input_number(user, "Set the fee", "Fee", max_value = 10000)
- if(isnull(force_fee_input) || !user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
- return
- force_fee = round(force_fee_input)
- return
- locked = !locked
- to_chat(user, span_notice("You [src.locked ? "lock" : "unlock"] the paystand, protecting the bolts from [anchored ? "loosening" : "tightening"]."))
- return
- if(!my_card)
- var/obj/item/card/id/new_card = W
- if(!new_card.registered_account)
- return
- var/msg = tgui_input_text(user, "Name of pay stand", "Paystand Naming", "Paystand (owned by [new_card.registered_account.account_holder])")
- if(!msg)
- return
- name = msg
- desc = "Owned by [new_card.registered_account.account_holder], pays directly into [user.p_their()] account."
- my_card = new_card
- to_chat(user, "You link the stand to your account.")
- return
- var/obj/item/card/id/pay_card = W
- if(pay_card.registered_account)
- if(!pay_card.registered_account.account_job)//Departmental budget cards like cargo's fall under this
- to_chat(user, span_warning("ERROR: Personal use of department budgets is not authorized."))
- return
- var/credit_amount = 0
- if(!force_fee)
- credit_amount = tgui_input_number(user, "How much would you like to deposit?", "Money Deposit")
- if(isnull(credit_amount))
- return
- credit_amount = round(credit_amount)
- else
- credit_amount = force_fee
- if(!user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
- return
- if(credit_amount < 1)
- to_chat(user, span_warning("ERROR: Invalid amount designated."))
- return
- if(pay_card.registered_account.adjust_money(-credit_amount))
- purchase(pay_card.registered_account.account_holder, credit_amount)
- say("Thank you for your patronage, [pay_card.registered_account.account_holder]!")
- playsound(src, 'sound/effects/cashregister.ogg', 20, TRUE)
- return
- else
- to_chat(user, span_warning("ERROR: Account has insufficient funds to make transaction."))
- return
- else
- to_chat(user, span_warning("ERROR: No bank account assigned to identification card."))
- return
- if(istype(W, /obj/item/holochip))
- var/obj/item/holochip/H = W
- var/cashmoney = round(tgui_input_number(user, "How much would you like to deposit?", "Money Deposit"))
- if(isnull(cashmoney))
- return
- cashmoney = round(cashmoney)
- if(H.spend(cashmoney, FALSE))
- purchase(user, cashmoney)
- to_chat(user, "Thanks for purchasing! The vendor has been informed.")
- return
- else
- to_chat(user, span_warning("ERROR: Insufficient funds to make transaction."))
- return
- if(istype(W, /obj/item/stack/spacecash))
- to_chat(user, "What is this, the 2000s? We only take card here.")
- return
- if(istype(W, /obj/item/coin))
- to_chat(user, "What is this, the 1800s? We only take card here.")
- return
- if(istype(W, /obj/item/assembly/signaler))
- var/obj/item/assembly/signaler/S = W
- if(S.secured)
- to_chat(user, span_warning("The signaler needs to be in attachable mode to add it to the paystand!"))
- return
- if(!my_card)
- to_chat(user, span_warning("ERROR: No identification card has been assigned to this paystand yet!"))
- return
- if(!isnull(signaler))
- to_chat(user, span_warning("A signaler is already attached to this unit!"))
- return
- var/cash_limit = tgui_input_number(user, "Enter the minimum amount of cash needed to deposit before the signaler is activated.", "Signaler Activation Threshold", 1, min_value = 1)
- if(isnull(cash_limit))
- return
- cash_limit = round(cash_limit)
- S.forceMove(src)
- signaler = S
- signaler_threshold = cash_limit
- to_chat(user, "You attach the signaler to the paystand.")
- desc += " A signaler appears to be attached to the scanner."
-
- if(default_deconstruction_screwdriver(user, "card_scanner", "card_scanner", W))
- return
-
- else if(default_pry_open(W))
- return
-
- else if(default_unfasten_wrench(user, W))
- return
-
- else if(default_deconstruction_crowbar(W))
- return
- else
- return ..()
-
-/obj/machinery/paystand/proc/purchase(buyer, price)
- my_card.registered_account.adjust_money(price)
- my_card.registered_account.bank_card_talk("Purchase made at your vendor by [buyer] for [price] credits.")
- amount_deposited = amount_deposited + price
- if(signaler && amount_deposited >= signaler_threshold)
- signaler.signal()
- amount_deposited = 0
-
-/obj/machinery/paystand/default_unfasten_wrench(mob/user, obj/item/I, time = 20)
- if(locked)
- to_chat(user, span_warning("The bolts on this paystand are currently covered!"))
- return FALSE
- . = ..()
-
-/obj/machinery/paystand/examine(mob/user)
- . = ..()
- if(force_fee)
- . += span_warning("This paystand forces a payment of [force_fee] credit\s per swipe instead of a variable amount.")
- if(user.get_active_held_item() == my_card)
- . += span_notice("Paystands can be edited through swiping your card.")
diff --git a/code/modules/research/designs/machine_designs.dm b/code/modules/research/designs/machine_designs.dm
index 4d17f148dec..e1787215787 100644
--- a/code/modules/research/designs/machine_designs.dm
+++ b/code/modules/research/designs/machine_designs.dm
@@ -572,14 +572,6 @@
category = list ("Research Machinery")
departmental_flags = DEPARTMENTAL_FLAG_CARGO
-/datum/design/board/paystand
- name = "Machine Design (Pay Stand)"
- desc = "The circuit board for a paystand."
- id = "paystand"
- build_path = /obj/item/circuitboard/machine/paystand
- category = list ("Misc. Machinery")
-
-
/datum/design/board/fat_sucker
name = "Machine Design (Lipid Extractor)"
desc = "The circuit board for a lipid extractor."
diff --git a/code/modules/research/techweb/all_nodes.dm b/code/modules/research/techweb/all_nodes.dm
index 771f0811a01..a29538a3bec 100644
--- a/code/modules/research/techweb/all_nodes.dm
+++ b/code/modules/research/techweb/all_nodes.dm
@@ -35,7 +35,6 @@
"micro_mani",
"oven_tray",
"packagewrap",
- "paystand",
"plasmaglass",
"plasmareinforcedglass",
"plasteel",
diff --git a/code/modules/vending/_vending.dm b/code/modules/vending/_vending.dm
index b3c06a23d68..dbbd5df74d6 100644
--- a/code/modules/vending/_vending.dm
+++ b/code/modules/vending/_vending.dm
@@ -1128,7 +1128,7 @@ GLOBAL_LIST_EMPTY(vending_products)
light_mask = "custom-light-mask"
refill_canister = /obj/item/vending_refill/custom
/// where the money is sent
- var/datum/bank_account/private_a
+ var/datum/bank_account/linked_account
/// max number of items that the custom vendor can hold
var/max_loaded_items = 20
/// Base64 cache of custom icons.
@@ -1139,9 +1139,9 @@ GLOBAL_LIST_EMPTY(vending_products)
. = FALSE
if(!isliving(user))
return FALSE
- var/mob/living/L = user
- var/obj/item/card/id/C = L.get_idcard(FALSE)
- if(C?.registered_account && C.registered_account == private_a)
+ var/mob/living/living_user = user
+ var/obj/item/card/id/id_card = living_user.get_idcard(FALSE)
+ if(id_card?.registered_account && id_card.registered_account == linked_account)
return TRUE
/obj/machinery/vending/custom/canLoadItem(obj/item/I, mob/user)
@@ -1158,6 +1158,12 @@ GLOBAL_LIST_EMPTY(vending_products)
if(I.custom_price)
return TRUE
+/obj/machinery/vending/custom/ui_interact(mob/user)
+ if(!linked_account)
+ balloon_alert(user, "no registered owner")
+ return FALSE
+ return ..()
+
/obj/machinery/vending/custom/ui_data(mob/user)
. = ..()
.["access"] = compartmentLoadAccessCheck(user)
@@ -1191,68 +1197,18 @@ GLOBAL_LIST_EMPTY(vending_products)
return
switch(action)
if("dispense")
- . = TRUE
- if(!vend_ready)
- return
- var/N = params["item"]
- var/obj/item/S
- vend_ready = FALSE
- var/obj/item/card/id/C
if(isliving(usr))
- var/mob/living/L = usr
- C = L.get_idcard(TRUE)
- if(!C)
- say("No card found.")
- flick(icon_deny,src)
- vend_ready = TRUE
- return
- else if (!C.registered_account)
- say("No account found.")
- flick(icon_deny,src)
- vend_ready = TRUE
- return
- var/datum/bank_account/account = C.registered_account
- for(var/obj/O in contents)
- if(format_text(O.name) == N)
- S = O
- break
- if(S)
- if(compartmentLoadAccessCheck(usr))
- vending_machine_input[N] = max(vending_machine_input[N] - 1, 0)
- S.forceMove(drop_location())
- loaded_items--
- use_power(5)
- vend_ready = TRUE
- updateUsrDialog()
- return
- if(account.has_money(S.custom_price))
- account.adjust_money(-S.custom_price)
- var/datum/bank_account/owner = private_a
- if(owner)
- owner.adjust_money(S.custom_price)
- SSblackbox.record_feedback("amount", "vending_spent", S.custom_price)
- log_econ("[S.custom_price] credits were spent on [src] buying a [S] by [owner.account_holder], owned by [private_a.account_holder].")
- vending_machine_input[N] = max(vending_machine_input[N] - 1, 0)
- S.forceMove(drop_location())
- loaded_items--
- use_power(5)
- if(last_shopper != REF(usr) || purchase_message_cooldown < world.time)
- say("Thank you for buying local and purchasing [S]!")
- purchase_message_cooldown = world.time + 5 SECONDS
- last_shopper = REF(usr)
- vend_ready = TRUE
- updateUsrDialog()
- return
- else
- say("You do not possess the funds to purchase this.")
+ vend_act(usr, params["item"])
vend_ready = TRUE
+ updateUsrDialog()
+ return TRUE
/obj/machinery/vending/custom/attackby(obj/item/I, mob/user, params)
- if(!private_a && isliving(user))
+ if(!linked_account && isliving(user))
var/mob/living/L = user
var/obj/item/card/id/C = L.get_idcard(TRUE)
if(C?.registered_account)
- private_a = C.registered_account
+ linked_account = C.registered_account
say("\The [src] has been linked to [C].")
if(compartmentLoadAccessCheck(user))
@@ -1277,6 +1233,57 @@ GLOBAL_LIST_EMPTY(vending_products)
explosion(src, devastation_range = -1, light_impact_range = 3)
return ..()
+/**
+ * Vends an item to the user. Handles all the logic:
+ * Updating stock, account transactions, alerting users.
+ * @return -- TRUE if a valid condition was met, FALSE otherwise.
+ */
+/obj/machinery/vending/custom/proc/vend_act(mob/living/user, choice)
+ if(!vend_ready)
+ return
+ var/obj/item/dispensed_item
+ var/obj/item/card/id/id_card = user.get_idcard(TRUE)
+ vend_ready = FALSE
+ if(!id_card || !id_card.registered_account || !id_card.registered_account.account_job)
+ balloon_alert(usr, "no card found")
+ flick(icon_deny, src)
+ return TRUE
+ var/datum/bank_account/payee = id_card.registered_account
+ for(var/obj/stock in contents)
+ if(format_text(stock.name) == choice)
+ dispensed_item = stock
+ break
+ if(!dispensed_item)
+ return FALSE
+ /// Charges the user if its not the owner
+ if(!compartmentLoadAccessCheck(user))
+ if(!payee.has_money(dispensed_item.custom_price))
+ balloon_alert(user, "insufficient funds")
+ return TRUE
+ /// Make the transaction
+ payee.adjust_money(-dispensed_item.custom_price)
+ linked_account.adjust_money(dispensed_item.custom_price)
+ linked_account.bank_card_talk("[payee.account_holder] made a [dispensed_item.custom_price] \
+ cr purchase at your custom vendor.")
+ /// Log the transaction
+ SSblackbox.record_feedback("amount", "vending_spent", dispensed_item.custom_price)
+ log_econ("[dispensed_item.custom_price] credits were spent on [src] buying a \
+ [dispensed_item] by [payee.account_holder], owned by [linked_account.account_holder].")
+ /// Make an alert
+ if(last_shopper != REF(usr) || purchase_message_cooldown < world.time)
+ say("Thank you for your patronage [user]!")
+ purchase_message_cooldown = world.time + 5 SECONDS
+ last_shopper = REF(usr)
+ /// Remove the item
+ loaded_items--
+ use_power(5)
+ vending_machine_input[choice] = max(vending_machine_input[choice] - 1, 0)
+ if(user.CanReach(src) && user.put_in_hands(dispensed_item))
+ to_chat(user, span_notice("You take [dispensed_item.name] out of the slot."))
+ else
+ to_chat(user, span_warning("[capitalize(dispensed_item.name)] falls onto the floor!"))
+ return TRUE
+
/obj/machinery/vending/custom/unbreakable
name = "Indestructible Vendor"
resistance_flags = INDESTRUCTIBLE
diff --git a/tgstation.dme b/tgstation.dme
index 4a16e2e5cb6..1f45bf7de61 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2505,7 +2505,7 @@
#include "code\modules\discord\toggle_notify.dm"
#include "code\modules\economy\_economy.dm"
#include "code\modules\economy\account.dm"
-#include "code\modules\economy\pay_stand.dm"
+#include "code\modules\economy\holopay.dm"
#include "code\modules\emoji\emoji_parse.dm"
#include "code\modules\error_handler\error_handler.dm"
#include "code\modules\error_handler\error_viewer.dm"
diff --git a/tgui/packages/tgui/components/RestrictedInput.js b/tgui/packages/tgui/components/RestrictedInput.js
new file mode 100644
index 00000000000..c4ee1ea1a0d
--- /dev/null
+++ b/tgui/packages/tgui/components/RestrictedInput.js
@@ -0,0 +1,154 @@
+import { classes } from 'common/react';
+import { clamp } from 'common/math';
+import { Component, createRef } from 'inferno';
+import { Box } from './Box';
+import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes';
+
+const DEFAULT_MIN = 0;
+const DEFAULT_MAX = 10000;
+
+/**
+ * Takes a string input and parses integers from it.
+ * If none: Minimum is set.
+ * Else: Clamps it to the given range.
+ */
+const getClampedNumber = (value, minValue, maxValue) => {
+ const minimum = minValue || DEFAULT_MIN;
+ const maximum = maxValue || maxValue === 0 ? maxValue : DEFAULT_MAX;
+ if (!value || !value.length) {
+ return String(minimum);
+ }
+ let parsedValue = parseInt(value.replace(/\D/g, ''), 10);
+ if (isNaN(parsedValue)) {
+ return String(minimum);
+ } else {
+ return String(clamp(parsedValue, minimum, maximum));
+ }
+};
+
+export class RestrictedInput extends Component {
+ constructor() {
+ super();
+ this.inputRef = createRef();
+ this.state = {
+ editing: false,
+ };
+ this.handleBlur = (e) => {
+ const { editing } = this.state;
+ if (editing) {
+ this.setEditing(false);
+ }
+ };
+ this.handleChange = (e) => {
+ const { maxValue, minValue, onChange } = this.props;
+ e.target.value = getClampedNumber(e.target.value, minValue, maxValue);
+ if (onChange) {
+ onChange(e, +e.target.value);
+ }
+ };
+ this.handleFocus = (e) => {
+ const { editing } = this.state;
+ if (!editing) {
+ this.setEditing(true);
+ }
+ };
+ this.handleInput = (e) => {
+ const { editing } = this.state;
+ const { onInput } = this.props;
+ if (!editing) {
+ this.setEditing(true);
+ }
+ if (onInput) {
+ onInput(e, +e.target.value);
+ }
+ };
+ this.handleKeyDown = (e) => {
+ const { maxValue, minValue, onChange, onEnter } = this.props;
+ if (e.keyCode === KEY_ENTER) {
+ const safeNum = getClampedNumber(e.target.value, minValue, maxValue);
+ this.setEditing(false);
+ if (onChange) {
+ onChange(e, safeNum);
+ }
+ if (onEnter) {
+ onEnter(e, safeNum);
+ }
+ e.target.blur();
+ return;
+ }
+ if (e.keyCode === KEY_ESCAPE) {
+ if (this.props.onEscape) {
+ this.props.onEscape(e);
+ return;
+ }
+ this.setEditing(false);
+ e.target.value = this.props.value;
+ e.target.blur();
+ return;
+ }
+ };
+ }
+
+ componentDidMount() {
+ const { maxValue, minValue } = this.props;
+ const nextValue = this.props.value?.toString();
+ const input = this.inputRef.current;
+ if (input) {
+ input.value = getClampedNumber(nextValue, minValue, maxValue);
+ }
+ if (this.props.autoFocus || this.props.autoSelect) {
+ setTimeout(() => {
+ input.focus();
+
+ if (this.props.autoSelect) {
+ input.select();
+ }
+ }, 1);
+ }
+ }
+
+ componentDidUpdate(prevProps, _) {
+ const { maxValue, minValue } = this.props;
+ const { editing } = this.state;
+ const prevValue = prevProps.value?.toString();
+ const nextValue = this.props.value?.toString();
+ const input = this.inputRef.current;
+ if (input && !editing) {
+ if (nextValue !== prevValue && nextValue !== input.value) {
+ input.value = getClampedNumber(nextValue, minValue, maxValue);
+ }
+ }
+ }
+
+ setEditing(editing) {
+ this.setState({ editing });
+ }
+
+ render() {
+ const { props } = this;
+ const { onChange, onEnter, onInput, value, ...boxProps } = props;
+ const { className, fluid, monospace, ...rest } = boxProps;
+ return (
+
+ .
+
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js
index 48cd147df39..121d2bfa4e2 100644
--- a/tgui/packages/tgui/components/index.js
+++ b/tgui/packages/tgui/components/index.js
@@ -33,6 +33,7 @@ export { NoticeBox } from './NoticeBox';
export { NumberInput } from './NumberInput';
export { ProgressBar } from './ProgressBar';
export { Popper } from './Popper';
+export { RestrictedInput } from './RestrictedInput';
export { RoundGauge } from './RoundGauge';
export { Section } from './Section';
export { Slider } from './Slider';
diff --git a/tgui/packages/tgui/interfaces/HoloPay.tsx b/tgui/packages/tgui/interfaces/HoloPay.tsx
new file mode 100644
index 00000000000..13fe55b4fec
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/HoloPay.tsx
@@ -0,0 +1,237 @@
+import { useBackend, useLocalState } from '../backend';
+import { Box, Button, Dropdown, Icon, NoticeBox, RestrictedInput, Section, Stack, Table, TextArea, Tooltip } from '../components';
+import { Window } from '../layouts';
+
+type HoloPayData = {
+ available_logos: string[];
+ balance: number;
+ description: string;
+ force_fee: number;
+ max_fee: number;
+ name: string;
+ owner: string;
+ shop_logo: string;
+ user: { name: string; balance: number };
+};
+
+const COPYRIGHT_SCROLLER = `Nanotrasen (c) 2525-2562. All sales final.
+Use of departmental funds is prohibited. For more information, visit
+the Head of Personnel. All rights reserved. All trademarks are property
+of their respective owners.`;
+
+export const HoloPay = (_, context) => {
+ const { data } = useBackend(context);
+ const { owner } = data;
+ const [setupMode, setSetupMode] = useLocalState(context, 'setupMode', false);
+ // User clicked the "Setup" or "Done" button.
+ const onClick = () => {
+ setSetupMode(!setupMode);
+ };
+
+ return (
+
+
+ {!owner ? (
+ Error! Swipe an ID first.
+ ) : (
+
+
+
+
+
+ {!setupMode ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+ );
+};
+
+/**
+ * Displays the current user's bank information (if any)
+ */
+const AccountDisplay = (_, context) => {
+ const { data } = useBackend(context);
+ const { user } = data;
+ if (!user) {
+ return Error! No account detected.;
+ }
+
+ return (
+
+
+
+
+
+
+ {user?.name}
+
+
+
+
+ {user?.balance} cr
+
+
+
+
+
+ );
+};
+
+/**
+ * Displays the payment processor. This is the main display.
+ * Shows icon, name, payment button.
+ */
+const TerminalDisplay = (props, context) => {
+ const { act, data } = useBackend(context);
+ const { description, force_fee, name, owner, user, shop_logo } = data;
+ const { onClick } = props;
+ const is_owner = owner === user?.name;
+ const cannot_pay
+ = is_owner || !user || user?.balance < 1 || user?.balance < force_fee;
+ const decodedName = name.replace(/(\d+);/g, (_, dec) => {
+ return String.fromCharCode(dec);
+ });
+
+ return (
+
+ Setup
+ )
+ }
+ fill
+ title="Terminal">
+
+
+
+
+
+
+
+ {decodedName}
+
+
+
+
+ {force_fee ? (
+
+
+ Pay {force_fee + ' cr'}
+ >
+ }
+ disabled={cannot_pay}
+ fluid
+ height="2rem"
+ onClick={() => act('pay')}
+ pt={0.2}
+ textAlign="center"
+ />
+ ) : (
+
+
+ {/* @ts-ignore */}
+
+
+
+
+ );
+};
+
+/**
+ * User has clicked "setup" button. Changes vars on the holopay.
+ */
+const SetupDisplay = (props, context) => {
+ const { act, data } = useBackend(context);
+ const { available_logos = [], force_fee, max_fee, name, shop_logo } = data;
+ const { onClick } = props;
+ const decodedName = name.replace(/(\d+);/g, (_, dec) => {
+ return String.fromCharCode(dec);
+ });
+
+ return (
+ {
+ act('done');
+ onClick();
+ }}>
+ Done
+
+ }
+ fill
+ scrollable
+ title="Settings">
+
+
+
+ Shop Logo
+
+ act('logo', { logo: value })}
+ options={available_logos}
+ selected={shop_logo}
+ width="100%"
+ />
+
+
+
+ Name (3 - 42 chars)
+
+
+
+
+
+ Forced Fee
+
+ act('fee', { amount: value })}
+ value={force_fee}
+ />
+
+
+
+
+ );
+};