diff --git a/Content.Client/_LuaM/Yupi/YupiUi.cs b/Content.Client/_LuaM/Yupi/YupiUi.cs
new file mode 100644
index 00000000000..83ca87fc6b8
--- /dev/null
+++ b/Content.Client/_LuaM/Yupi/YupiUi.cs
@@ -0,0 +1,29 @@
+using Content.Client.UserInterface.Fragments;
+using Content.Shared._LuaM.Yupi;
+using Content.Shared.CartridgeLoader;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._LuaM.Yupi;
+
+public sealed partial class YupiUi : UIFragment
+{
+ private YupiUiFragment? _fragment;
+
+ public override Control GetUIFragmentRoot()
+ {
+ return _fragment!;
+ }
+
+ public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner)
+ {
+ _fragment = new YupiUiFragment();
+ _fragment.OnSend += (code, amount) =>
+ userInterface.SendMessage(new CartridgeUiMessage(new YupiTransferMessage(code, amount)));
+ }
+
+ public override void UpdateState(BoundUserInterfaceState state)
+ {
+ if (state is YupiUiState yupiState)
+ _fragment?.UpdateState(yupiState);
+ }
+}
diff --git a/Content.Client/_LuaM/Yupi/YupiUiFragment.xaml b/Content.Client/_LuaM/Yupi/YupiUiFragment.xaml
new file mode 100644
index 00000000000..5e4706d48d3
--- /dev/null
+++ b/Content.Client/_LuaM/Yupi/YupiUiFragment.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_LuaM/Yupi/YupiUiFragment.xaml.cs b/Content.Client/_LuaM/Yupi/YupiUiFragment.xaml.cs
new file mode 100644
index 00000000000..b18eb42c15c
--- /dev/null
+++ b/Content.Client/_LuaM/Yupi/YupiUiFragment.xaml.cs
@@ -0,0 +1,131 @@
+using Content.Shared._LuaM.Yupi;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client._LuaM.Yupi;
+
+[GenerateTypedNameReferences]
+public sealed partial class YupiUiFragment : BoxContainer
+{
+ [Dependency] private IClipboardManager _clipboard = default!;
+ [Dependency] private IGameTiming _timing = default!;
+
+ private static readonly Color HintColor = Color.FromHex("#A0A0A0");
+
+ public event Action? OnSend;
+
+ private int _balance;
+ private int _lowRateRemaining;
+ private TimeSpan _nextTransfer;
+
+ private int _shownCooldownSeconds = -1;
+
+ public YupiUiFragment()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ CodeInput.IsValid = text => text.Length <= YupiRules.CodeLength;
+ AmountInput.IsValid = text => text.Length == 0 || TryParseAmount(text, out var value) && value >= 0;
+
+ CodeInput.OnTextChanged += _ => UpdatePreview();
+ AmountInput.OnTextChanged += _ => UpdatePreview();
+
+ CopyCodeButton.OnPressed += _ =>
+ {
+ if (YupiRules.IsValidCode(CopyCodeButton.Text ?? string.Empty))
+ _clipboard.SetText(CopyCodeButton.Text!);
+ };
+
+ SendButton.OnPressed += _ =>
+ {
+ if (TryGetInput(out var code, out var amount))
+ OnSend?.Invoke(code, amount);
+ };
+
+ UpdatePreview();
+ }
+
+ public void UpdateState(YupiUiState state)
+ {
+ var hasAccount = state.OwnCode != string.Empty;
+ NoAccountLabel.Visible = !hasAccount;
+ AccountBox.Visible = hasAccount;
+
+ CopyCodeButton.Text = hasAccount ? state.OwnCode : "------";
+ BalanceLabel.Text = Loc.GetString("yupi-ui-credits", ("amount", YupiRules.FormatCredits(state.Balance)));
+ LowRateLabel.Text = Loc.GetString("yupi-ui-credits", ("amount", YupiRules.FormatCredits(state.LowRateRemaining)));
+ _balance = state.Balance;
+ _lowRateRemaining = state.LowRateRemaining;
+ _nextTransfer = state.NextTransfer;
+ _shownCooldownSeconds = -1;
+ UpdatePreview();
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ var seconds = GetCooldownSeconds();
+ if (seconds == _shownCooldownSeconds)
+ return;
+
+ _shownCooldownSeconds = seconds;
+ UpdateSendButton();
+ }
+
+ private void UpdatePreview()
+ {
+ string text;
+ if (!TryParseAmount(AmountInput.Text, out var amount) || amount <= 0)
+ {
+ text = Loc.GetString("yupi-ui-commission-hint",
+ ("low", YupiRules.LowRatePercent),
+ ("high", YupiRules.HighRatePercent));
+ }
+ else
+ {
+ var commission = YupiRules.GetCommission(amount, _lowRateRemaining);
+ text = Loc.GetString("yupi-ui-commission-preview",
+ ("commission", YupiRules.FormatCredits(commission)),
+ ("net", YupiRules.FormatCredits(amount - commission)));
+ }
+
+ CommissionLabel.SetMessage(FormattedMessage.FromUnformatted(text), HintColor);
+ UpdateSendButton();
+ }
+
+ private void UpdateSendButton()
+ {
+ var seconds = GetCooldownSeconds();
+ SendButton.Text = seconds > 0
+ ? Loc.GetString("yupi-ui-send-cooldown", ("time", $"{seconds / 60}:{seconds % 60:D2}"))
+ : Loc.GetString("yupi-ui-send");
+ SendButton.Disabled = seconds > 0 || !TryGetInput(out _, out _);
+ }
+
+ private int GetCooldownSeconds()
+ {
+ var remaining = _nextTransfer - _timing.CurTime;
+ return remaining > TimeSpan.Zero ? (int) Math.Ceiling(remaining.TotalSeconds) : 0;
+ }
+
+ private bool TryGetInput(out string code, out int amount)
+ {
+ code = YupiRules.NormalizeCode(CodeInput.Text);
+ return TryParseAmount(AmountInput.Text, out amount)
+ && amount > 0
+ && amount <= _balance
+ && amount > YupiRules.GetCommission(amount, _lowRateRemaining)
+ && YupiRules.IsValidCode(code);
+ }
+
+ private static bool TryParseAmount(string text, out int amount)
+ {
+ return int.TryParse(text.Replace(".", string.Empty), out amount);
+ }
+}
diff --git a/Content.Server/_LuaM/Yupi/YupiCartridgeSystem.cs b/Content.Server/_LuaM/Yupi/YupiCartridgeSystem.cs
new file mode 100644
index 00000000000..8bca417409b
--- /dev/null
+++ b/Content.Server/_LuaM/Yupi/YupiCartridgeSystem.cs
@@ -0,0 +1,141 @@
+using Content.Server._NF.Bank;
+using Content.Server.CartridgeLoader;
+using Content.Shared._LuaM.Yupi;
+using Content.Shared._NF.Bank.Components;
+using Content.Shared.CartridgeLoader;
+using Content.Shared.Popups;
+using Robust.Shared.Containers;
+using Robust.Shared.Player;
+
+namespace Content.Server._LuaM.Yupi;
+
+public sealed partial class YupiCartridgeSystem : EntitySystem
+{
+ [Dependency] private BankSystem _bank = default!;
+ [Dependency] private CartridgeLoaderSystem _cartridgeLoader = default!;
+ [Dependency] private SharedContainerSystem _container = default!;
+ [Dependency] private ISharedPlayerManager _player = default!;
+ [Dependency] private SharedPopupSystem _popup = default!;
+ [Dependency] private YupiSystem _yupi = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnUiReady);
+ SubscribeLocalEvent(OnMessage);
+ }
+
+ private void OnUiReady(Entity ent, ref CartridgeUiReadyEvent args)
+ {
+ UpdateUi(args.Loader);
+ }
+
+ private void OnMessage(Entity ent, ref CartridgeMessageEvent args)
+ {
+ if (args is not YupiTransferMessage message)
+ return;
+
+ var loader = GetEntity(args.LoaderUid);
+
+ if (!TryGetHolder(loader, out var holder) || holder != args.Actor)
+ return;
+
+ var result = _yupi.TryTransfer(holder, message.TargetCode, message.Amount, out var target, out var commission, out var received);
+ if (result == YupiTransferResult.Success)
+ {
+ var net = message.Amount - commission;
+ _popup.PopupEntity(Loc.GetString("yupi-transfer-sent",
+ ("amount", YupiRules.FormatCredits(message.Amount)),
+ ("code", _yupi.GetOrCreateCode(target)),
+ ("commission", YupiRules.FormatCredits(commission)),
+ ("net", YupiRules.FormatCredits(net))),
+ holder,
+ holder);
+
+ var savings = net - received;
+ _popup.PopupEntity(Loc.GetString("yupi-transfer-received",
+ ("amount", YupiRules.FormatCredits(received)),
+ ("code", _yupi.GetOrCreateCode(holder)),
+ ("hasSavings", savings > 0 ? "yes" : "no"),
+ ("savings", YupiRules.FormatCredits(savings))),
+ target,
+ target);
+
+ RefreshOpenApps(target);
+ }
+ else
+ {
+ _popup.PopupEntity(Loc.GetString(GetErrorLoc(result)), holder, holder, PopupType.SmallCaution);
+ }
+
+ UpdateUi(loader);
+ }
+
+ private void UpdateUi(EntityUid loader)
+ {
+ var code = string.Empty;
+ var balance = 0;
+ var lowRateRemaining = 0;
+ var nextTransfer = TimeSpan.Zero;
+
+ if (TryGetHolder(loader, out var holder))
+ {
+ code = _yupi.GetOrCreateCode(holder);
+ _bank.TryGetBalance(holder, out balance);
+ if (_player.TryGetSessionByEntity(holder, out var session))
+ {
+ lowRateRemaining = _yupi.GetLowRateRemaining(session.UserId);
+ nextTransfer = _yupi.GetNextTransferTime(session.UserId);
+ }
+ }
+
+ _cartridgeLoader.UpdateCartridgeUiState(loader, new YupiUiState(code, balance, lowRateRemaining, nextTransfer));
+ }
+
+ private void RefreshOpenApps(EntityUid holder)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var loader, out var loaderComp))
+ {
+ if (!HasComp(loaderComp.ActiveProgram)
+ || !TryGetHolder(loader, out var loaderHolder)
+ || loaderHolder != holder)
+ continue;
+
+ UpdateUi(loader);
+ }
+ }
+
+ private bool TryGetHolder(EntityUid loader, out EntityUid holder)
+ {
+ var current = loader;
+ while (_container.TryGetContainingContainer((current, null, null), out var container))
+ {
+ current = container.Owner;
+ if (!HasComp(current))
+ continue;
+
+ holder = current;
+ return true;
+ }
+
+ holder = default;
+ return false;
+ }
+
+ private static string GetErrorLoc(YupiTransferResult result)
+ {
+ return result switch
+ {
+ YupiTransferResult.InvalidAmount => "yupi-error-invalid-amount",
+ YupiTransferResult.InvalidTarget => "yupi-error-invalid-target",
+ YupiTransferResult.SelfTransfer => "yupi-error-self-transfer",
+ YupiTransferResult.NoAccount => "yupi-no-account",
+ YupiTransferResult.TargetUnavailable => "yupi-error-target-unavailable",
+ YupiTransferResult.InsufficientFunds => "yupi-error-insufficient-funds",
+ YupiTransferResult.Cooldown => "yupi-error-cooldown",
+ _ => "yupi-error-disabled",
+ };
+ }
+}
diff --git a/Content.Server/_LuaM/Yupi/YupiComponents.cs b/Content.Server/_LuaM/Yupi/YupiComponents.cs
new file mode 100644
index 00000000000..269e07674c2
--- /dev/null
+++ b/Content.Server/_LuaM/Yupi/YupiComponents.cs
@@ -0,0 +1,13 @@
+namespace Content.Server._LuaM.Yupi;
+
+[RegisterComponent, Access(typeof(YupiSystem))]
+public sealed partial class YupiAccountComponent : Component
+{
+ [ViewVariables]
+ public string Code = string.Empty;
+}
+
+[RegisterComponent]
+public sealed partial class YupiCartridgeComponent : Component
+{
+}
diff --git a/Content.Server/_LuaM/Yupi/YupiSystem.cs b/Content.Server/_LuaM/Yupi/YupiSystem.cs
new file mode 100644
index 00000000000..4a9d8228918
--- /dev/null
+++ b/Content.Server/_LuaM/Yupi/YupiSystem.cs
@@ -0,0 +1,182 @@
+using Content.Server._NF.Bank;
+using Content.Server.Administration.Logs;
+using Content.Shared._LuaM.Yupi;
+using Content.Shared._Mono.CCVar;
+using Content.Shared._Mono.Traits.Physical;
+using Content.Shared._NF.Bank.Components;
+using Content.Shared.Database;
+using Robust.Shared.Configuration;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server._LuaM.Yupi;
+
+public enum YupiTransferResult : byte
+{
+ Success,
+ InvalidAmount,
+ InvalidTarget,
+ SelfTransfer,
+ NoAccount,
+ TargetUnavailable,
+ InsufficientFunds,
+ Cooldown,
+ Disabled,
+}
+
+public sealed partial class YupiSystem : EntitySystem
+{
+ [Dependency] private BankSystem _bank = default!;
+ [Dependency] private IAdminLogManager _adminLogger = default!;
+ [Dependency] private IConfigurationManager _cfg = default!;
+ [Dependency] private IGameTiming _timing = default!;
+ [Dependency] private ISharedPlayerManager _player = default!;
+ [Dependency] private IRobustRandom _random = default!;
+
+ private readonly Dictionary> _sent = new();
+
+ private readonly Dictionary _lastTransfer = new();
+
+ public string GetOrCreateCode(EntityUid mob)
+ {
+ if (!HasComp(mob) || HasComp(mob))
+ return string.Empty;
+
+ var account = EnsureComp(mob);
+ if (account.Code == string.Empty)
+ account.Code = GenerateUniqueCode();
+
+ return account.Code;
+ }
+
+ public bool TryFindByCode(string code, out EntityUid mob)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var account))
+ {
+ if (account.Code != code)
+ continue;
+
+ mob = uid;
+ return true;
+ }
+
+ mob = default;
+ return false;
+ }
+
+ public TimeSpan GetNextTransferTime(NetUserId user)
+ {
+ return _lastTransfer.TryGetValue(user, out var last) ? last + YupiRules.Cooldown : TimeSpan.Zero;
+ }
+
+ public int GetLowRateRemaining(NetUserId user)
+ {
+ if (!_sent.TryGetValue(user, out var sent))
+ return YupiRules.LowRateLimit;
+
+ var cutoff = _timing.CurTime - YupiRules.LowRateWindow;
+ while (sent.Count > 0 && sent.Peek().Time <= cutoff)
+ sent.Dequeue();
+
+ long total = 0;
+ foreach (var (_, amount) in sent)
+ total += amount;
+
+ if (sent.Count == 0)
+ _sent.Remove(user);
+
+ return (int)Math.Max(0, YupiRules.LowRateLimit - total);
+ }
+
+ public YupiTransferResult TryTransfer(EntityUid sender,
+ string targetCode,
+ int amount,
+ out EntityUid target,
+ out int commission,
+ out int received)
+ {
+ target = default;
+ commission = 0;
+ received = 0;
+
+ if (amount <= 0)
+ return YupiTransferResult.InvalidAmount;
+
+ if (!_cfg.GetCVar(MonoCVars.DepositEnabled))
+ return YupiTransferResult.Disabled;
+
+ if (!_player.TryGetSessionByEntity(sender, out var senderSession))
+ return YupiTransferResult.InsufficientFunds;
+
+ if (HasComp(sender))
+ return YupiTransferResult.NoAccount;
+
+ var user = senderSession.UserId;
+ var now = _timing.CurTime;
+ if (now < GetNextTransferTime(user))
+ return YupiTransferResult.Cooldown;
+
+ targetCode = YupiRules.NormalizeCode(targetCode);
+ if (!YupiRules.IsValidCode(targetCode) || !TryFindByCode(targetCode, out target))
+ return YupiTransferResult.InvalidTarget;
+
+ if (target == sender)
+ return YupiTransferResult.SelfTransfer;
+
+ if (HasComp(target))
+ return YupiTransferResult.InvalidTarget;
+
+ if (!TryComp(target, out var targetBank) || !_bank.TryGetBalance(target, out _))
+ return YupiTransferResult.TargetUnavailable;
+
+ commission = (int) YupiRules.GetCommission(amount, GetLowRateRemaining(user));
+ var net = amount - commission;
+ if (net <= 0)
+ return YupiTransferResult.InvalidAmount;
+
+ if (!_bank.TryGetBalance(sender, out var balance) || balance < amount)
+ return YupiTransferResult.InsufficientFunds;
+
+ _bank.GetTaxedDepositAmount(net, targetBank.Balance, out received, out _);
+
+ if (!_bank.TryBankWithdraw(sender, amount))
+ return YupiTransferResult.InsufficientFunds;
+
+ if (!_bank.TryBankDeposit(target, net))
+ {
+ _bank.TryBankDeposit(sender, amount, tax: false);
+ Log.Error($"YUPI transfer of {amount} from {ToPrettyString(sender)} to {ToPrettyString(target)} failed on deposit, refunded");
+ return YupiTransferResult.TargetUnavailable;
+ }
+
+ if (!_sent.TryGetValue(user, out var sent))
+ _sent[user] = sent = new Queue<(TimeSpan, int)>();
+ sent.Enqueue((now, amount));
+ _lastTransfer[user] = now;
+
+ _adminLogger.Add(LogType.ATMUsage,
+ LogImpact.Medium,
+ $"{ToPrettyString(sender):actor} sent {amount} via YUPI to {ToPrettyString(target):subject} (commission {commission}, received {net}, of which {net - received} went to savings)");
+
+ return YupiTransferResult.Success;
+ }
+
+ private string GenerateUniqueCode()
+ {
+ Span buffer = stackalloc char[YupiRules.CodeLength];
+ string code;
+ do
+ {
+ for (var i = 0; i < buffer.Length; i++)
+ buffer[i] = YupiRules.CodeAlphabet[_random.Next(YupiRules.CodeAlphabet.Length)];
+
+ code = new string(buffer);
+ }
+ while (TryFindByCode(code, out _));
+
+ return code;
+ }
+}
diff --git a/Content.Shared/_LuaM/Yupi/YupiRules.cs b/Content.Shared/_LuaM/Yupi/YupiRules.cs
new file mode 100644
index 00000000000..a13520b629d
--- /dev/null
+++ b/Content.Shared/_LuaM/Yupi/YupiRules.cs
@@ -0,0 +1,62 @@
+using Content.Shared._NF.Bank;
+
+namespace Content.Shared._LuaM.Yupi;
+
+public static class YupiRules
+{
+ public const int LowRateLimit = 250_000;
+
+ public const int LowRatePercent = 3;
+
+ public const int HighRatePercent = 10;
+
+ public static readonly TimeSpan LowRateWindow = TimeSpan.FromMinutes(30);
+
+ public static readonly TimeSpan Cooldown = TimeSpan.FromMinutes(1);
+
+ public const int CodeLength = 6;
+
+ public const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ123456789";
+
+ public static long GetCommission(int amount, int lowRateRemaining)
+ {
+ if (amount <= 0)
+ return 0;
+
+ long lowPart = Math.Clamp(lowRateRemaining, 0, amount);
+ long highPart = amount - lowPart;
+ return CeilPercent(lowPart, LowRatePercent) + CeilPercent(highPart, HighRatePercent);
+ }
+
+ public static string FormatCredits(long amount)
+ {
+ return BankSystemExtensions.ToCurrencyString(amount,
+ symbolOverride: string.Empty,
+ separatorOverride: ".",
+ symbolLocation: BankSystemExtensions.CurrencySymbolLocation.Prefix);
+ }
+
+ public static string NormalizeCode(string code)
+ {
+ return code.Trim().ToUpperInvariant();
+ }
+
+ public static bool IsValidCode(string code)
+ {
+ if (code.Length != CodeLength)
+ return false;
+
+ foreach (var ch in code)
+ {
+ if (!CodeAlphabet.Contains(ch))
+ return false;
+ }
+
+ return true;
+ }
+
+ private static long CeilPercent(long value, int percent)
+ {
+ return (value * percent + 99) / 100;
+ }
+}
diff --git a/Content.Shared/_LuaM/Yupi/YupiUiState.cs b/Content.Shared/_LuaM/Yupi/YupiUiState.cs
new file mode 100644
index 00000000000..e6fdd9799a1
--- /dev/null
+++ b/Content.Shared/_LuaM/Yupi/YupiUiState.cs
@@ -0,0 +1,37 @@
+using Content.Shared.CartridgeLoader;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._LuaM.Yupi;
+
+[Serializable, NetSerializable]
+public sealed class YupiUiState : BoundUserInterfaceState
+{
+ public readonly string OwnCode;
+
+ public readonly int Balance;
+
+ public readonly int LowRateRemaining;
+
+ public readonly TimeSpan NextTransfer;
+
+ public YupiUiState(string ownCode, int balance, int lowRateRemaining, TimeSpan nextTransfer)
+ {
+ OwnCode = ownCode;
+ Balance = balance;
+ LowRateRemaining = lowRateRemaining;
+ NextTransfer = nextTransfer;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class YupiTransferMessage : CartridgeMessageEvent
+{
+ public readonly string TargetCode;
+ public readonly int Amount;
+
+ public YupiTransferMessage(string targetCode, int amount)
+ {
+ TargetCode = targetCode;
+ Amount = amount;
+ }
+}
diff --git a/Resources/Locale/en-US/_LuaM/yupi/yupi.ftl b/Resources/Locale/en-US/_LuaM/yupi/yupi.ftl
new file mode 100644
index 00000000000..72236a02417
--- /dev/null
+++ b/Resources/Locale/en-US/_LuaM/yupi/yupi.ftl
@@ -0,0 +1,31 @@
+yupi-program-name = YUPI Transfers
+yupi-bank-name = PromGradBank
+yupi-no-account = Bank account unavailable.
+
+yupi-ui-own-code = Your YUPI:
+yupi-ui-copy-tooltip = Copy code
+yupi-ui-balance = Balance:
+yupi-ui-low-rate-remaining = Low-rate allowance:
+yupi-ui-credits = { $amount } cr.
+yupi-ui-target-code = Recipient code:
+yupi-ui-amount = Amount:
+yupi-ui-send = Send
+yupi-ui-send-cooldown = Send in { $time }
+yupi-ui-commission-hint =
+ Commission is { $low }% within the allowance and { $high }% above it.
+ The allowance recovers over 30 minutes.
+yupi-ui-commission-preview = Commission: { $commission } cr. Recipient gets: { $net } cr.
+
+yupi-transfer-sent = Sent { $amount } cr. to account { $code }. Commission: { $commission } cr., recipient got { $net } cr.
+yupi-transfer-received = Incoming YUPI transfer from account { $code }: { $amount } cr.{ $hasSavings ->
+ [yes] {" "}Another { $savings } cr. went to savings.
+ *[no] {""}
+}
+
+yupi-error-invalid-amount = Invalid amount.
+yupi-error-invalid-target = No account with that code was found.
+yupi-error-self-transfer = You cannot transfer to yourself.
+yupi-error-target-unavailable = The recipient is currently unavailable.
+yupi-error-insufficient-funds = Insufficient funds.
+yupi-error-cooldown = Your next transfer will be available in a minute.
+yupi-error-disabled = Transfers are temporarily unavailable.
diff --git a/Resources/Locale/ru-RU/_LuaM/yupi/yupi.ftl b/Resources/Locale/ru-RU/_LuaM/yupi/yupi.ftl
new file mode 100644
index 00000000000..f28e3c585ad
--- /dev/null
+++ b/Resources/Locale/ru-RU/_LuaM/yupi/yupi.ftl
@@ -0,0 +1,31 @@
+yupi-program-name = ЮПИ Переводы
+yupi-bank-name = PromGradBank
+yupi-no-account = Банковский счёт недоступен.
+
+yupi-ui-own-code = Ваш ЮПИ:
+yupi-ui-copy-tooltip = Скопировать код
+yupi-ui-balance = Баланс:
+yupi-ui-low-rate-remaining = Льготный лимит:
+yupi-ui-credits = { $amount } кр.
+yupi-ui-target-code = Код получателя:
+yupi-ui-amount = Сумма:
+yupi-ui-send = Отправить
+yupi-ui-send-cooldown = Отправить через { $time }
+yupi-ui-commission-hint =
+ Комиссия { $low }% в пределах лимита, { $high }% сверх него.
+ Лимит восстанавливается в течение 30 минут.
+yupi-ui-commission-preview = Комиссия: { $commission } кр. Получателю дойдёт: { $net } кр.
+
+yupi-transfer-sent = Перевод { $amount } кр. на счёт { $code } выполнен. Комиссия: { $commission } кр., получателю дошло { $net } кр.
+yupi-transfer-received = Входящий перевод ЮПИ со счёта { $code }: { $amount } кр.{ $hasSavings ->
+ [yes] {" "}Ещё { $savings } кр. ушло в сбережения.
+ *[no] {""}
+}
+
+yupi-error-invalid-amount = Неверная сумма.
+yupi-error-invalid-target = Счёт с таким кодом не найден.
+yupi-error-self-transfer = Нельзя переводить самому себе.
+yupi-error-target-unavailable = Получатель сейчас недоступен.
+yupi-error-insufficient-funds = Недостаточно средств.
+yupi-error-cooldown = Следующий перевод будет доступен через минуту.
+yupi-error-disabled = Переводы временно недоступны.
diff --git a/Resources/Locale/ru-RU/ss14-ru/prototypes/_LuaM/entities/objects/Devices/cartridges.ftl b/Resources/Locale/ru-RU/ss14-ru/prototypes/_LuaM/entities/objects/Devices/cartridges.ftl
new file mode 100644
index 00000000000..8ae1d83754d
--- /dev/null
+++ b/Resources/Locale/ru-RU/ss14-ru/prototypes/_LuaM/entities/objects/Devices/cartridges.ftl
@@ -0,0 +1,2 @@
+ent-YupiCartridge = картридж ЮПИ Переводы
+ .desc = Программа для перевода кредитов другим людям по их коду ЮПИ.
diff --git a/Resources/Prototypes/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
index 7ef0f5bec72..011c9cf7aaa 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
@@ -80,6 +80,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge # Frontier
- AstroNavCartridge # Frontier
@@ -152,6 +153,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge # Frontier
- AstroNavCartridge # Frontier
@@ -167,6 +169,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge # Frontier
- AstroNavCartridge # Frontier
@@ -881,6 +884,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- WantedListCartridge
- LogProbeCartridge
@@ -949,6 +953,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- MedTekCartridge
- WantedListCartridge
@@ -980,6 +985,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- LogProbeCartridge
- WantedListCartridge
@@ -1103,6 +1109,7 @@
uiKey: enum.PdaUiKey.Key
preinstalled:
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- AstroNavCartridge # Frontier
- type: entity
@@ -1130,6 +1137,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- MedTekCartridge
- WantedListCartridge
@@ -1312,6 +1320,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- WantedListCartridge
- LogProbeCartridge
@@ -1339,6 +1348,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- WantedListCartridge
- MedTekCartridge
@@ -1491,6 +1501,7 @@
uiKey: enum.PdaUiKey.Key
preinstalled:
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- MedTekCartridge
- type: entity
diff --git a/Resources/Prototypes/_Goobstation/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/_Goobstation/Entities/Objects/Devices/pda.yml
index ff878fc3d0d..644a0245656 100644
--- a/Resources/Prototypes/_Goobstation/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/_Goobstation/Entities/Objects/Devices/pda.yml
@@ -43,6 +43,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- LogProbeCartridge
- MedTekCartridge
diff --git a/Resources/Prototypes/_LuaM/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/_LuaM/Entities/Objects/Devices/cartridges.yml
new file mode 100644
index 00000000000..06bc90d6462
--- /dev/null
+++ b/Resources/Prototypes/_LuaM/Entities/Objects/Devices/cartridges.yml
@@ -0,0 +1,18 @@
+- type: entity
+ parent: NFBasePDACartridge
+ id: YupiCartridge
+ name: YUPI transfers cartridge
+ description: A program for transferring credits to other people by their YUPI code.
+ components:
+ - type: Sprite
+ sprite: _NF/Objects/Devices/cartridge.rsi
+ state: cart-ledger
+ - type: Icon
+ sprite: _NF/Objects/Devices/cartridge.rsi
+ state: cart-ledger
+ - type: UIFragment
+ ui: !type:YupiUi
+ - type: Cartridge
+ programName: yupi-program-name
+ icon: { sprite: _NF/Interface/icons.rsi, state: ledger }
+ - type: YupiCartridge
diff --git a/Resources/Prototypes/_Mono/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/_Mono/Entities/Objects/Devices/pda.yml
index 387d94b630b..ede7485c408 100644
--- a/Resources/Prototypes/_Mono/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/_Mono/Entities/Objects/Devices/pda.yml
@@ -8,6 +8,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
@@ -38,6 +39,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
@@ -188,6 +190,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- MedTekCartridge
- WantedListCartridge
@@ -213,6 +216,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- MedTekCartridge
- WantedListCartridge
@@ -235,6 +239,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
@@ -297,6 +302,7 @@
- LedgerCartridge
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
diff --git a/Resources/Prototypes/_NF/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/_NF/Entities/Objects/Devices/pda.yml
index 396061a26bc..bf42efd0ab0 100644
--- a/Resources/Prototypes/_NF/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/_NF/Entities/Objects/Devices/pda.yml
@@ -16,6 +16,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
@@ -142,6 +143,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
@@ -309,6 +311,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
@@ -381,6 +384,7 @@
preinstalled:
- CrewManifestCartridge
- NotekeeperCartridge
+ - YupiCartridge # LuaM
- NewsReaderCartridge
- BountyContractsCartridge
- AstroNavCartridge
diff --git a/Resources/Textures/_LuaM/Yupi/bank_logo.png b/Resources/Textures/_LuaM/Yupi/bank_logo.png
new file mode 100644
index 00000000000..a18965cf094
Binary files /dev/null and b/Resources/Textures/_LuaM/Yupi/bank_logo.png differ
diff --git a/Resources/Textures/_LuaM/Yupi/bank_logo.png.yml b/Resources/Textures/_LuaM/Yupi/bank_logo.png.yml
new file mode 100644
index 00000000000..5c43e233050
--- /dev/null
+++ b/Resources/Textures/_LuaM/Yupi/bank_logo.png.yml
@@ -0,0 +1,2 @@
+sample:
+ filter: true