diff --git a/Core/Resgrid.Config/BusinessOperationsAddonConfig.cs b/Core/Resgrid.Config/BusinessOperationsAddonConfig.cs new file mode 100644 index 000000000..051baa363 --- /dev/null +++ b/Core/Resgrid.Config/BusinessOperationsAddonConfig.cs @@ -0,0 +1,31 @@ +namespace Resgrid.Config +{ + /// + /// Department-level monthly Business Operations add-on prices and provider product ids (Workforce & Business + /// Operations plan, decision 42; PlanAddonTypes.BusinessOperations = 4). Provider checkout lives in the Billing API + /// (int-CommonApis), cloned from Readiness Pro; Core only proxies it. Environment keys: + /// RESGRID:BusinessOperationsAddonConfig:StripeProductId, :StripeTestProductId, :StripePriceId, :StripeTestPriceId, + /// :PaddleProductId, :PaddleTestProductId, :StripeMonthlyAmount, :PaddleMonthlyAmount. The Paddle price id lives in + /// PaymentProviderConfig.PaddleBusinessOperationsAddon (the Readiness Pro convention). Live ids set 2026-09-18: + /// Stripe product prod_VHnlBsvKsSpqeP / price price_0UHEA6qJFDZJcnkVnj0ZaAFw (USD 250/month), Paddle product + /// pro_01m2vrcv37k2pnqseb22d8r244 / price pri_01m2vrdycmx8kfhys5xxcjgqnx (EUR 295/month); test-mode ids are still empty. + /// + public static class BusinessOperationsAddonConfig + { + /// Fixed PlanAddons row id so every data center's catalog row matches (M0211; the PTT/ADP/Readiness Pro convention). + public const string PlanAddonId = "8c2f0d6e-5b1a-4f2e-9d3c-7a6b5e4d3c2b"; + + public static string StripeProductId = "prod_VHnlBsvKsSpqeP"; + public static string StripeTestProductId = ""; + public static string StripePriceId = "price_0UHEA6qJFDZJcnkVnj0ZaAFw"; + public static string StripeTestPriceId = ""; + public static string PaddleProductId = "pro_01m2vrcv37k2pnqseb22d8r244"; + public static string PaddleTestProductId = ""; + + /// USD per month through Stripe (US cluster). Set 2026-09-18. + public static decimal StripeMonthlyAmount = 250m; + + /// EUR per month through Paddle (EU cluster). Set 2026-09-18. + public static decimal PaddleMonthlyAmount = 295m; + } +} diff --git a/Core/Resgrid.Config/PaymentConnectConfig.cs b/Core/Resgrid.Config/PaymentConnectConfig.cs new file mode 100644 index 000000000..01c5760b1 --- /dev/null +++ b/Core/Resgrid.Config/PaymentConnectConfig.cs @@ -0,0 +1,69 @@ +namespace Resgrid.Config +{ + /// + /// Department-connected Stripe accounts for collecting payments on Resgrid invoices (Workforce & Business + /// Operations plan, Phase B2). Off unless this switch is on; the operator feature flag Payments.StripeConnect is + /// the second, per-cluster lock, so a cluster that does not offer payment collection leaves both off. These are + /// the platform's own Connect credentials and are deliberately separate from PaymentProviderConfig, which serves + /// Resgrid's SaaS subscription billing even when both point at the same Stripe account. Environment keys: + /// RESGRID:PaymentConnectConfig:Enabled, :CredentialPassphrase, :PublicBaseUrl, :StripeClientId, :StripeSecretKey, + /// :StripeConnectWebhookSecret, :StripeLiveMode, :PayLinkTokenTtlDays, :RequestReconcileAfterMinutes, + /// :EventRetentionDays, :PayPageRateLimitPerMinute, :WebhookStaleAfterHours, :WebhookEndpointProbeEnabled. + /// + public static class PaymentConnectConfig + { + /// Process-level master switch. Off means no provider is registered, no connection can be made and the webhook endpoint answers 503. + public static bool Enabled = false; + + /// + /// Passphrase for the symmetric encryption of stored provider tokens. Stripe Connect stores no per-account token, + /// so it is unused in v1, but a later token-holding provider (Square, PayPal, Authorize.net) cannot be enabled without it. + /// + public static string CredentialPassphrase = ""; + + /// Public origin of this cluster (for example https://api.resgrid.com). The pay page, the OAuth redirect and the webhook URL are built from this and nothing else. + public static string PublicBaseUrl = ""; + + /// Connect OAuth client id of the platform (ca_...). Live and sandbox ids differ. + public static string StripeClientId = ""; + + /// The platform's secret key used for the OAuth token exchange and for every call made as a connected account. + public static string StripeSecretKey = ""; + + /// Signing secret of the Connect-scoped webhook endpoint (events from connected accounts). Never the SaaS endpoint's secret. + public static string StripeConnectWebhookSecret = ""; + + /// Whether the keys above are live-mode keys. A webhook whose livemode does not match is rejected. + public static bool StripeLiveMode = false; + + /// How long an e-mailed or printed pay-page link stays valid. + public static int PayLinkTokenTtlDays = 30; + + /// Age after which an open payment request is polled at Stripe by the invoice maintenance worker instead of waiting for a webhook. + public static int RequestReconcileAfterMinutes = 10; + + /// Days raw webhook bodies are kept for forensics before the worker purges them. + public static int EventRetentionDays = 90; + + /// Per-IP request limit on the anonymous pay page. + public static int PayPageRateLimitPerMinute = 20; + + /// Hours without any webhook event, while there was payment activity in the last seven days, before the health check reports the webhook as stale. + public static int WebhookStaleAfterHours = 24; + + /// Whether the health check may ask Stripe whether an enabled webhook endpoint exists at this cluster's webhook URL (one call per process per fifteen minutes). + public static bool WebhookEndpointProbeEnabled = true; + + /// Route of the Connect webhook receiver on the Web.Services host. A constant, so the config processor never overwrites it. + public const string WebhookPath = "/api/PaymentWebhooks/stripe"; + + /// The webhook URL Stripe must be configured with for this cluster, or an empty string when PublicBaseUrl is not set. + public static string GetWebhookUrl() + { + if (string.IsNullOrWhiteSpace(PublicBaseUrl)) + return string.Empty; + + return PublicBaseUrl.TrimEnd('/') + WebhookPath; + } + } +} diff --git a/Core/Resgrid.Config/PaymentProviderConfig.cs b/Core/Resgrid.Config/PaymentProviderConfig.cs index dd1552765..9978e03c6 100644 --- a/Core/Resgrid.Config/PaymentProviderConfig.cs +++ b/Core/Resgrid.Config/PaymentProviderConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text.RegularExpressions; namespace Resgrid.Config @@ -48,6 +48,11 @@ public static class PaymentProviderConfig // configured separately; a missing sandbox price must never fall back to production. public static string PaddleReadinessProAddon = "pri_01m20xy5x54j0sp4mcydcm4q6m"; public static string PaddleReadinessProAddonTest = ""; + + // Business Operations: EUR 295/month, Paddle product pro_01m2vrcv37k2pnqseb22d8r244 (plan decision 42). Stripe + // USD 250/month (price_0UHEA6qJFDZJcnkVnj0ZaAFw) is seeded on PlanAddons by M0211; the test-mode ids are still empty. + public static string PaddleBusinessOperationsAddon = "pri_01m2vrdycmx8kfhys5xxcjgqnx"; + public static string PaddleBusinessOperationsAddonTest = ""; public static string PaddleProductionEnvironment = "production"; public static string PaddleTestEnvironment = "sandbox"; public static string PaddleProductionClientToken = ""; @@ -164,6 +169,11 @@ public static string GetPaddleReadinessProAddonPriceId() return NormalizeConfigValue(IsTestMode ? PaddleReadinessProAddonTest : PaddleReadinessProAddon); } + public static string GetPaddleBusinessOperationsAddonPriceId() + { + return NormalizeConfigValue(IsTestMode ? PaddleBusinessOperationsAddonTest : PaddleBusinessOperationsAddon); + } + public static string GetPaddleEnvironment() { if (IsTestMode) diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx new file mode 100644 index 000000000..428a23fac --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + الفوترة + إدارة الفوترة + عرض الفوترة + إنشاء الفواتير وتعديلها وإرسالها وإلغاؤها، وتسجيل المدفوعات، وإدارة بطاقات الأسعار وملفات الفوترة. + عرض الفواتير وتقادم الذمم المدينة وملفات PDF للفواتير. + تم إنشاء الفاتورة + تم إرسال الفاتورة + تم تسجيل دفعة الفاتورة + تم دفع الفاتورة + الفاتورة متأخرة + تم إلغاء الفاتورة + تم رد دفعة الفاتورة + تم الاعتراض على دفعة الفاتورة + الفوترة + الفواتير + فواتير + فاتورة + فواتير العملاء والمدفوعات والذمم المدينة. + إضافة Business Operations غير نشطة لهذا القسم. تبقى الفواتير الحالية قابلة للقراءة؛ يتطلب الإنشاء أو التغيير إضافة نشطة. + Business Operations + الفوترة وبطاقات الأسعار وملفات الفوترة. تتطلب الصفحات أيضًا إضافة Business Operations. + بطاقات الأسعار + بطاقة الأسعار + أسعار الفوترة المطبقة عند إضافة البلاغات إلى فاتورة. + تسرد بطاقة الأسعار ما تفرضه: وقت الوحدات أو الأفراد بالساعة، رسوم ثابتة لكل بلاغ، رسوم محددة، المسافة والمواد. حدد بطاقة كافتراضية للقسم؛ يمكن لملف فوترة العميل تثبيت بطاقة أخرى. + بطاقة أسعار جديدة + لا توجد بطاقات أسعار بعد. + عناصر بطاقة الأسعار + عنصر بطاقة الأسعار + عنصر جديد + لا تحتوي بطاقة الأسعار هذه على عناصر بعد. + احفظ بطاقة الأسعار قبل إضافة العناصر. + افتراضي القسم (يُستخدم عندما لا يكون للعميل بطاقة أسعار مثبتة) + هل تريد حذف بطاقة الأسعار هذه؟ + هل تريد حذف هذا العنصر؟ + تم حذف بطاقة الأسعار. + النوع + بالساعة – وقت الوحدة في الموقع + بالساعة – وقت الأفراد + رسوم ثابتة لكل بلاغ + رسوم محددة + المسافة + مواد + السعر + تسمية الوحدة + الحد الأدنى للرسوم + الحد الأدنى للدقائق + التقريب إلى (دقائق) + التقريب + الحد الأدنى + التقريب إلى + نوع الوحدة + أي نوع وحدة + يُطبع في سطر الفاتورة؛ الافتراضي هو اسم العنصر. + ترتيب الفرز + خاضع للضريبة + نشط + غير نشط + افتراضي + الاسم + الوصف + تعديل + فتح + إضافة + إزالة + الكل + تصفية + جارٍ التحميل… + الحالة + مسودة + مرسلة + مدفوعة جزئيًا + مدفوعة + متأخرة + ملغاة + الرقم + العميل + تاريخ الإصدار + تاريخ الاستحقاق + اتركه فارغًا لاستخدام شروط الملف: أيام صافية + أُرسلت في + أُلغيت في + تاريخ الدفع + الإجمالي + الإجماليات + المجموع الفرعي + الخصم + الخصم % + الضريبة + المبلغ المدفوع + الرصيد + الرصيد المستحق + الرصيد المتأخر + فاتورة جديدة + اختر العميل؛ تأخذ المسودة الشروط والخصم وبطاقة الأسعار من ملف الفوترة. + اختر عميلًا… + عملاء لديهم ملف فوترة + جهات اتصال أخرى + لا يملك جهة الاتصال هذه ملف فوترة بعد. + إعداد ملف الفوترة + اختر عميلًا لديه ملف فوترة نشط. + العملة + إنشاء مسودة + حرر المسودة، أضف البلاغات من بطاقة أسعار، ثم عاين وأرسل. + ملاحظات + الشروط + البنود + لا توجد بنود. + الكمية + السعر + المبلغ + إضافة بند + إضافة بلاغ + تُنشأ البنود من بطاقة الأسعار ووقت الوحدات في الموقع؛ عدّلها قبل الحفظ. + رقم البلاغ + البلاغ + وقت التسجيل + لا توجد بلاغات مرتبطة بهذا العميل. + موجود في هذه الفاتورة + تمت إضافة بنود البلاغ. احفظ الفاتورة للاحتفاظ بها. + تعذر تحميل البيانات. + تعذر حفظ التغيير. + تم الحفظ. + القيم المرسلة غير صالحة. + الاسم مطلوب. + يتطلب ذلك إضافة Business Operations نشطة. + يُعاد حساب الخصم والضريبة عند الحفظ. + معاينة وإرسال + العودة إلى الفواتير + الشروط (أيام صافية) + معفى من الضريبة + نسبة الضريبة % + ملف الفوترة + إلى أين تُرسل الفواتير، وشروط الدفع، والمعاملة الضريبية، وبطاقة الأسعار لهذا العميل. + لا يوجد ملف فوترة بعد. احفظ واحدًا لبدء فوترة جهة الاتصال هذه. + فتح جهة الاتصال + البريد الإلكتروني للفوترة + تُرسل الفواتير هنا؛ الافتراضي هو بريد جهة الاتصال. + الخصم الافتراضي % + بطاقة الأسعار + استخدام افتراضي القسم + رقم أمر الشراء مطلوب في الفواتير + نشط (يمكن إنشاء فواتير جديدة) + عنوان الفوترة + استخدام العنوان البريدي لجهة الاتصال + العنوان + المدينة + الولاية / المقاطعة + الرمز البريدي + الدولة + نسبة موحدة تُطبق على البنود الخاضعة للضريبة عندما لا تُدرج مكونات أدناه. + مكونات الضريبة + حتى ثلاث ضرائب مسماة (مثل GST وPST)، تُطبع كل منها برقم تسجيلها. + النسبة % + رقم التسجيل + كل فواتير هذا العميل + لا توجد فواتير. + المدفوعات + لم تُسجل أي مدفوعات. + الطريقة + شيك + نقدًا + تحويل بنكي / ACH + بطاقة (خارج Resgrid) + أخرى + عبر الإنترنت + حالة الدفع + ناجح + مسترد + مسترد جزئيًا + متنازع عليه + خسارة النزاع + المرجع + معاينة + الإجراءات + تعديل المسودة + تنزيل PDF + إرسال الفاتورة + إعادة إرسال الفاتورة + يرسل ملف PDF للفاتورة بالبريد الإلكتروني إلى العميل ويحدد المسودة كمرسلة. + إرسال إلى + إرسال + وضع علامة كمرسلة (بدون بريد إلكتروني) + هل تريد وضع علامة على هذه الفاتورة كمرسلة دون إرسالها بالبريد الإلكتروني؟ + تسجيل دفعة + المدفوعات اليدوية فقط؛ تُسجل المدفوعات عبر الإنترنت تلقائيًا. + تم تسجيل الدفعة. + إلغاء الفاتورة + تحتفظ الفاتورة الملغاة برقمها ولا يمكن تعديلها أو دفعها. + السبب + تم إرسال الفاتورة. + تم وضع علامة على الفاتورة كمرسلة. + تم إلغاء الفاتورة. + تعذر إنشاء ملف PDF الآن. + أعمار الديون + أعمار الذمم المدينة + الأرصدة المفتوحة مجمعة حسب مدة التأخر. + اعتبارًا من + جارية + يوم تأخير + لا توجد فواتير مستحقة. + إعدادات الفوترة + ما تعرضه فواتيرك عن قسمك، وخيارات الدفع عبر الإنترنت. + هوية الفوترة + يُطبع على كل فاتورة وملف PDF. + الاسم القانوني للمنشأة + عنوان السداد + التسجيلات + رقم التسجيل الضريبي + تسجيل ضريبي ثانوي + SAM UEI + رمز CAGE + حساب تعويض العمال + تذييل الفاتورة + تعليمات الدفع أو عبارة شكر أو نص قانوني يُطبع أسفل الإجماليات. + انتهاء رابط الدفع (أيام) + إظهار رابط "ادفع عبر الإنترنت" في الفواتير + المدفوعات عبر الإنترنت + تحصيل المدفوعات عبر الإنترنت (Stripe) غير متاح بعد. يمكن دفع الفواتير بشيك أو نقدًا أو تحويل بنكي أو بطاقة خارج Resgrid وتسجيلها يدويًا. + التوفر في المنطقة + علامة القسم + عند التوفر، يربط قسمك حساب Stripe الخاص به؛ Resgrid لا تحتفظ بالأموال أبدًا. + متاح + غير متاح في هذه المنطقة + مفعّل + معطّل + إضافة Business Operations + إضافة شهرية للأقسام التي تفوتر العملاء وتدير العقود. يمكن فقط للعضو المدير للقسم شراؤها أو إلغاؤها. + فوترة العملاء وبطاقات الأسعار والمدفوعات وأعمار الذمم المدينة + جداول أسعار المقاولين والعقود واسترداد التكاليف (لاحقًا) + بيانات رواتب القوى العاملة وتكاليف الميدان (لاحقًا) + الإضافة نشطة لقسمك. + شهر + لا يملك قسمك هذه الإضافة بعد. + مدفوع حتى + تم إلغاء التجديد؛ يستمر الوصول حتى نهاية الفترة المدفوعة. + شراء + إلغاء التجديد + هل تريد إلغاء تجديد Business Operations؟ + الفوترة غير متاحة الآن. يرجى المحاولة لاحقًا. + إدارة الاشتراك + إعدادات الوحدات + روابط + لم يتم العثور على البلاغ. + لم يتم العثور على جهة الاتصال. + أضف بندًا واحدًا على الأقل قبل الإرسال. + لا يمكن إلغاء فاتورة لها مدفوعات. + يمكن تعديل مسودة الفاتورة فقط. + لم يتم العثور على الفاتورة. + لا يمكن لهذه الفاتورة قبول دفعة في حالتها الحالية. + لا يمكن إلغاء فاتورة مدفوعة. + هذه الفاتورة ملغاة. + لم يُحدد بريد إلكتروني للفوترة لهذا العميل. + لم يتم العثور على الدفعة. + تعذر إنشاء ملف PDF للفاتورة. + ملف الفوترة هذا له فواتير ولا يمكن حذفه. + يحتاج العميل أولًا إلى ملف فوترة نشط. + لم يتم العثور على بطاقة الأسعار. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.cs b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.cs new file mode 100644 index 000000000..7b121424e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.Invoicing +{ + public class Invoicing { } +} diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx new file mode 100644 index 000000000..05b3037f1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Rechnungsstellung + Rechnungsstellung verwalten + Rechnungsstellung anzeigen + Rechnungen erstellen, bearbeiten, senden und stornieren, Zahlungen erfassen sowie Preislisten und Abrechnungsprofile verwalten. + Rechnungen, Forderungsalterung und Rechnungs-PDFs anzeigen. + Rechnung erstellt + Rechnung gesendet + Rechnungszahlung erfasst + Rechnung bezahlt + Rechnung überfällig + Rechnung storniert + Rechnungszahlung erstattet + Rechnungszahlung angefochten + Abrechnung + Rechnungen + Rechnungen + Rechnung + Kundenrechnungen, Zahlungen und Forderungen. + Das Business-Operations-Add-on ist für diese Abteilung nicht aktiv. Bestehende Rechnungen bleiben lesbar; Erstellen oder Ändern erfordert ein aktives Add-on. + Business Operations + Rechnungsstellung, Preislisten und Abrechnungsprofile. Die Seiten benötigen zusätzlich das Business-Operations-Add-on. + Preislisten + Preisliste + Abrechnungssätze, die beim Hinzufügen von Einsätzen zu einer Rechnung angewendet werden. + Eine Preisliste enthält Ihre Sätze: Stunden für Einheiten oder Personal, Pauschalen pro Einsatz, Festgebühren, Kilometer und Material. Markieren Sie eine Liste als Abteilungsstandard; ein Abrechnungsprofil kann eine andere festlegen. + Neue Preisliste + Noch keine Preislisten. + Positionen der Preisliste + Position der Preisliste + Neue Position + Diese Preisliste hat noch keine Positionen. + Speichern Sie die Preisliste, bevor Sie Positionen hinzufügen. + Abteilungsstandard (verwendet, wenn ein Kunde keine feste Preisliste hat) + Diese Preisliste löschen? + Diese Position löschen? + Preisliste gelöscht. + Typ + Stündlich – Einsatzzeit der Einheit + Stündlich – Personalzeit + Pauschale pro Einsatz + Festgebühr + Kilometer + Material + Satz + Einheitenbezeichnung + Mindestbetrag + Mindestminuten + Aufrunden auf (Minuten) + Rundung + Min. + runden auf + Einheitentyp + Jeder Einheitentyp + Wird auf der Rechnungszeile gedruckt; standardmäßig der Positionsname. + Sortierung + Steuerpflichtig + Aktiv + Inaktiv + Standard + Name + Beschreibung + Bearbeiten + Öffnen + Hinzufügen + Entfernen + Alle + Filtern + Wird geladen… + Status + Entwurf + Gesendet + Teilweise bezahlt + Bezahlt + Überfällig + Storniert + Nummer + Kunde + Ausgestellt + Fällig + Leer lassen für die Profilbedingungen: Nettotage + Gesendet am + Storniert am + Bezahlt am + Gesamt + Summen + Zwischensumme + Rabatt + Rabatt % + Steuer + Bezahlter Betrag + Offener Betrag + Offene Forderungen + Überfälliger Betrag + Neue Rechnung + Wählen Sie den Kunden; der Entwurf übernimmt Bedingungen, Rabatt und Preisliste aus dem Abrechnungsprofil. + Kunden auswählen… + Kunden mit Abrechnungsprofil + Weitere Kontakte + Dieser Kontakt hat noch kein Abrechnungsprofil. + Abrechnungsprofil einrichten + Wählen Sie einen Kunden mit aktivem Abrechnungsprofil. + Währung + Entwurf erstellen + Entwurf bearbeiten, Einsätze aus einer Preisliste hinzufügen, dann Vorschau und Versand. + Notizen + Zahlungsbedingungen + Positionen + Keine Positionen. + Menge + Satz + Betrag + Position hinzufügen + Einsatz hinzufügen + Die Positionen werden aus der Preisliste und der Einsatzzeit der Einheiten erzeugt; bearbeiten Sie sie vor dem Speichern. + Einsatz-Nr. + Einsatz + Erfasst + Mit diesem Kunden sind keine Einsätze verknüpft. + Bereits auf dieser Rechnung + Einsatzpositionen hinzugefügt. Speichern Sie die Rechnung, um sie zu behalten. + Die Daten konnten nicht geladen werden. + Die Änderung konnte nicht gespeichert werden. + Gespeichert. + Die übermittelten Werte sind ungültig. + Ein Name ist erforderlich. + Dafür ist ein aktives Business-Operations-Add-on erforderlich. + Rabatt und Steuer werden beim Speichern neu berechnet. + Vorschau & Versand + Zurück zu den Rechnungen + Zahlungsziel (Nettotage) + Steuerbefreit + Steuersatz % + Abrechnungsprofil + Wohin Rechnungen gehen, Zahlungsbedingungen, steuerliche Behandlung und die Preisliste für diesen Kunden. + Noch kein Abrechnungsprofil. Speichern Sie eines, um diesen Kontakt abzurechnen. + Kontakt öffnen + Rechnungs-E-Mail + Rechnungen werden hierhin gesendet; standardmäßig die E-Mail des Kontakts. + Standardrabatt % + Preisliste + Abteilungsstandard verwenden + Bestellnummer auf Rechnungen erforderlich + Aktiv (neue Rechnungen können erstellt werden) + Rechnungsadresse + Postanschrift des Kontakts verwenden + Adresse + Ort + Bundesland + Postleitzahl + Land + Pauschalsatz für steuerpflichtige Positionen, wenn unten keine Komponenten angegeben sind. + Steuerkomponenten + Bis zu drei benannte Steuern (z. B. GST und PST), jeweils mit eigener Registrierungsnummer gedruckt. + Satz % + Registrierungsnummer + Alle Rechnungen dieses Kunden + Keine Rechnungen. + Zahlungen + Keine Zahlungen erfasst. + Zahlungsart + Scheck + Bar + Überweisung / ACH + Karte (außerhalb von Resgrid) + Sonstiges + Online-Zahlung + Zahlungsstatus + Erfolgreich + Erstattet + Teilweise erstattet + Angefochten + Anfechtung verloren + Referenz + Vorschau + Aktionen + Entwurf bearbeiten + PDF herunterladen + Rechnung senden + Rechnung erneut senden + Sendet das Rechnungs-PDF per E-Mail an den Kunden und markiert einen Entwurf als gesendet. + Senden an + Senden + Als gesendet markieren (ohne E-Mail) + Diese Rechnung als gesendet markieren, ohne sie per E-Mail zu senden? + Zahlung erfassen + Nur manuelle Zahlungen; Online-Zahlungen werden automatisch erfasst. + Zahlung erfasst. + Rechnung stornieren + Eine stornierte Rechnung behält ihre Nummer und kann weder bearbeitet noch bezahlt werden. + Grund + Rechnung gesendet. + Rechnung als gesendet markiert. + Rechnung storniert. + Das PDF konnte gerade nicht erstellt werden. + Fälligkeiten + Fälligkeitsanalyse der Forderungen + Offene Beträge, gruppiert nach Überfälligkeit. + Stand + Nicht fällig + Tage überfällig + Keine offenen Rechnungen. + Abrechnungseinstellungen + Was Ihre Rechnungen über Ihre Abteilung aussagen, und Optionen für Online-Zahlungen. + Rechnungsidentität + Wird auf jeder Rechnung und jedem PDF gedruckt. + Offizieller Firmenname + Zahlungsadresse + Registrierungen + Steuernummer + Zweite Steuernummer + SAM UEI + CAGE-Code + Unfallversicherungskonto + Rechnungsfußzeile + Zahlungshinweise, Dankestext oder rechtlicher Hinweis unter den Summen. + Gültigkeit des Zahlungslinks (Tage) + Link „Online bezahlen“ auf Rechnungen anzeigen + Online-Zahlungen + Die Online-Zahlungsannahme (Stripe) ist noch nicht verfügbar. Rechnungen können per Scheck, bar, Überweisung oder Karte außerhalb von Resgrid bezahlt und manuell erfasst werden. + Verfügbarkeit in der Region + Abteilungs-Flag + Sobald verfügbar, verbindet Ihre Abteilung ihr eigenes Stripe-Konto; Resgrid hält die Gelder nie. + Verfügbar + In dieser Region nicht verfügbar + Aktiviert + Deaktiviert + Business-Operations-Add-on + Ein monatliches Add-on für Abteilungen, die Kunden abrechnen und Verträge verwalten. Nur das verwaltende Mitglied der Abteilung kann es kaufen oder kündigen. + Kundenrechnungen, Preislisten, Zahlungen und Forderungsanalyse + Auftragnehmer-Preistabellen, Verträge und Kostenerstattung (folgt später) + Lohndaten und Einsatzkostenrechnung (folgt später) + Das Add-on ist für Ihre Abteilung aktiv. + Monat + Ihre Abteilung hat dieses Add-on noch nicht. + Bezahlt bis + Verlängerung gekündigt; der Zugang bleibt bis zum Ende des bezahlten Zeitraums bestehen. + Kaufen + Verlängerung kündigen + Die Verlängerung von Business Operations kündigen? + Die Abrechnung ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut. + Abonnement verwalten + Moduleinstellungen + Links + Der Einsatz wurde nicht gefunden. + Der Kontakt wurde nicht gefunden. + Fügen Sie vor dem Senden mindestens eine Position hinzu. + Eine Rechnung mit Zahlungen kann nicht storniert werden. + Nur ein Rechnungsentwurf kann bearbeitet werden. + Die Rechnung wurde nicht gefunden. + Diese Rechnung kann im aktuellen Status keine Zahlung annehmen. + Eine bezahlte Rechnung kann nicht storniert werden. + Diese Rechnung ist storniert. + Für diesen Kunden ist keine Rechnungs-E-Mail hinterlegt. + Die Zahlung wurde nicht gefunden. + Das Rechnungs-PDF konnte nicht erstellt werden. + Dieses Abrechnungsprofil hat Rechnungen und kann nicht gelöscht werden. + Der Kunde benötigt zuerst ein aktives Abrechnungsprofil. + Die Preisliste wurde nicht gefunden. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx new file mode 100644 index 000000000..923323d4b --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Τιμολόγηση + Διαχείριση τιμολόγησης + Προβολή τιμολόγησης + Δημιουργία, επεξεργασία, αποστολή και ακύρωση τιμολογίων, καταχώριση πληρωμών και διαχείριση τιμοκαταλόγων και προφίλ χρέωσης. + Προβολή τιμολογίων, χρονολογικής ανάλυσης απαιτήσεων και PDF τιμολογίων. + Το τιμολόγιο δημιουργήθηκε + Το τιμολόγιο στάλθηκε + Καταχωρίστηκε πληρωμή τιμολογίου + Το τιμολόγιο πληρώθηκε + Το τιμολόγιο είναι εκπρόθεσμο + Το τιμολόγιο ακυρώθηκε + Η πληρωμή τιμολογίου επιστράφηκε + Η πληρωμή τιμολογίου αμφισβητήθηκε + Χρέωση + Τιμολόγια + τιμολόγια + Τιμολόγιο + Τιμολόγια πελατών, πληρωμές και απαιτήσεις. + Το πρόσθετο Business Operations δεν είναι ενεργό για αυτό το τμήμα. Τα υπάρχοντα τιμολόγια παραμένουν αναγνώσιμα· η δημιουργία ή αλλαγή απαιτεί ενεργό πρόσθετο. + Business Operations + Τιμολόγηση, τιμοκατάλογοι και προφίλ χρέωσης. Οι σελίδες απαιτούν επίσης το πρόσθετο Business Operations. + Τιμοκατάλογοι + Τιμοκατάλογος + Χρεώσεις που εφαρμόζονται όταν κλήσεις προστίθενται σε τιμολόγιο. + Ο τιμοκατάλογος ορίζει τις χρεώσεις: ωριαίος χρόνος μονάδων ή προσωπικού, κατ' αποκοπή ανά κλήση, πάγια, χιλιόμετρα και υλικά. Ορίστε έναν ως προεπιλογή· ένα προφίλ χρέωσης μπορεί να ορίσει άλλον. + Νέος τιμοκατάλογος + Δεν υπάρχουν ακόμη τιμοκατάλογοι. + Στοιχεία τιμοκαταλόγου + Στοιχείο τιμοκαταλόγου + Νέο στοιχείο + Αυτός ο τιμοκατάλογος δεν έχει ακόμη στοιχεία. + Αποθηκεύστε τον τιμοκατάλογο πριν προσθέσετε στοιχεία. + Προεπιλογή τμήματος (όταν ο πελάτης δεν έχει ορισμένο τιμοκατάλογο) + Διαγραφή αυτού του τιμοκαταλόγου; + Διαγραφή αυτού του στοιχείου; + Ο τιμοκατάλογος διαγράφηκε. + Τύπος + Ωριαία – χρόνος μονάδας επί τόπου + Ωριαία – χρόνος προσωπικού + Κατ' αποκοπή ανά κλήση + Πάγιο + Χιλιόμετρα + Υλικό + Χρέωση + Ετικέτα μονάδας + Ελάχιστη χρέωση + Ελάχιστα λεπτά + Στρογγυλοποίηση σε (λεπτά) + Στρογγυλοποίηση + Ελάχ. + στρογγυλοποίηση σε + Τύπος μονάδας + Οποιοσδήποτε τύπος μονάδας + Εκτυπώνεται στη γραμμή τιμολογίου· προεπιλογή το όνομα του στοιχείου. + Σειρά ταξινόμησης + Φορολογητέο + Ενεργό + Ανενεργό + Προεπιλογή + Όνομα + Περιγραφή + Επεξεργασία + Άνοιγμα + Προσθήκη + Αφαίρεση + Όλα + Φίλτρο + Φόρτωση… + Κατάσταση + Πρόχειρο + Απεσταλμένο + Μερικώς εξοφλημένο + Εξοφλημένο + Ληξιπρόθεσμο + Ακυρωμένο + Αριθμός + Πελάτης + Εκδόθηκε + Λήξη + Αφήστε κενό για τους όρους του προφίλ: καθαρές ημέρες + Απεστάλη + Ακυρώθηκε + Πληρώθηκε + Σύνολο + Σύνολα + Υποσύνολο + Έκπτωση + Έκπτωση % + Φόρος + Ποσό που πληρώθηκε + Υπόλοιπο + Ανεξόφλητο υπόλοιπο + Ληξιπρόθεσμο υπόλοιπο + Νέο τιμολόγιο + Επιλέξτε πελάτη· το πρόχειρο παίρνει όρους, έκπτωση και τιμοκατάλογο από το προφίλ χρέωσης. + Επιλέξτε πελάτη… + Πελάτες με προφίλ χρέωσης + Άλλες επαφές + Αυτή η επαφή δεν έχει ακόμη προφίλ χρέωσης. + Ρύθμιση προφίλ χρέωσης + Επιλέξτε πελάτη με ενεργό προφίλ χρέωσης. + Νόμισμα + Δημιουργία πρόχειρου + Επεξεργαστείτε το πρόχειρο, προσθέστε κλήσεις από τιμοκατάλογο, μετά προεπισκόπηση και αποστολή. + Σημειώσεις + Όροι + Γραμμές + Δεν υπάρχουν γραμμές. + Ποσ. + Χρέωση + Ποσό + Προσθήκη γραμμής + Προσθήκη κλήσης + Οι γραμμές παράγονται από τον τιμοκατάλογο και τον χρόνο των μονάδων επί τόπου· επεξεργαστείτε τις πριν την αποθήκευση. + Αρ. κλήσης + Κλήση + Καταγράφηκε + Δεν υπάρχουν κλήσεις συνδεδεμένες με αυτόν τον πελάτη. + Ήδη στο τιμολόγιο + Οι γραμμές κλήσης προστέθηκαν. Αποθηκεύστε το τιμολόγιο για να διατηρηθούν. + Δεν ήταν δυνατή η φόρτωση των δεδομένων. + Η αλλαγή δεν ήταν δυνατό να αποθηκευτεί. + Αποθηκεύτηκε. + Οι τιμές που υποβλήθηκαν δεν είναι έγκυρες. + Απαιτείται όνομα. + Απαιτεί ενεργό πρόσθετο Business Operations. + Η έκπτωση και ο φόρος επανυπολογίζονται κατά την αποθήκευση. + Προεπισκόπηση & αποστολή + Επιστροφή στα τιμολόγια + Όροι (καθαρές ημέρες) + Απαλλαγή φόρου + Συντελεστής φόρου % + Προφίλ χρέωσης + Πού πηγαίνουν τα τιμολόγια, οι όροι πληρωμής, η φορολογική μεταχείριση και ο τιμοκατάλογος του πελάτη. + Δεν υπάρχει ακόμη προφίλ χρέωσης. Αποθηκεύστε ένα για να τιμολογήσετε αυτή την επαφή. + Άνοιγμα επαφής + E-mail χρέωσης + Τα τιμολόγια αποστέλλονται εδώ· προεπιλογή το e-mail της επαφής. + Προεπιλεγμένη έκπτωση % + Τιμοκατάλογος + Χρήση προεπιλογής τμήματος + Απαιτείται αριθμός παραγγελίας στα τιμολόγια + Ενεργό (επιτρέπεται η δημιουργία νέων τιμολογίων) + Διεύθυνση χρέωσης + Χρήση ταχυδρομικής διεύθυνσης επαφής + Διεύθυνση + Πόλη + Πολιτεία / νομός + Ταχυδρομικός κώδικας + Χώρα + Ενιαίος συντελεστής στις φορολογητέες γραμμές όταν δεν ορίζονται συνιστώσες παρακάτω. + Συνιστώσες φόρου + Έως τρεις ονομαστικοί φόροι (π.χ. GST και PST), καθένας με τον δικό του αριθμό μητρώου. + Συντελεστής % + Αριθμός μητρώου + Όλα τα τιμολόγια αυτού του πελάτη + Δεν υπάρχουν τιμολόγια. + Πληρωμές + Δεν έχουν καταγραφεί πληρωμές. + Μέθοδος + Επιταγή + Μετρητά + Τραπεζική μεταφορά / ACH + Κάρτα (εκτός Resgrid) + Άλλο + Διαδικτυακά + Κατάσταση πληρωμής + Επιτυχής + Επιστράφηκε + Μερικώς επιστράφηκε + Αμφισβητούμενη + Απώλεια αμφισβήτησης + Αναφορά + Προεπισκόπηση + Ενέργειες + Επεξεργασία πρόχειρου + Λήψη PDF + Αποστολή τιμολογίου + Επαναποστολή τιμολογίου + Στέλνει το PDF του τιμολογίου στον πελάτη με e-mail και σημειώνει το πρόχειρο ως απεσταλμένο. + Αποστολή σε + Αποστολή + Σήμανση ως απεσταλμένο (χωρίς e-mail) + Σήμανση του τιμολογίου ως απεσταλμένο χωρίς αποστολή e-mail; + Καταγραφή πληρωμής + Μόνο χειροκίνητες πληρωμές· οι διαδικτυακές καταγράφονται αυτόματα. + Η πληρωμή καταγράφηκε. + Ακύρωση τιμολογίου + Το ακυρωμένο τιμολόγιο κρατά τον αριθμό του και δεν μπορεί να τροποποιηθεί ή να πληρωθεί. + Αιτία + Το τιμολόγιο εστάλη. + Το τιμολόγιο σημειώθηκε ως απεσταλμένο. + Το τιμολόγιο ακυρώθηκε. + Δεν ήταν δυνατή η δημιουργία του PDF αυτή τη στιγμή. + Χρονολόγηση + Χρονολόγηση απαιτήσεων + Ανοικτά υπόλοιπα ομαδοποιημένα κατά καθυστέρηση. + Έως + Τρέχοντα + ημέρες καθυστέρησης + Δεν υπάρχουν ανεξόφλητα τιμολόγια. + Ρυθμίσεις χρέωσης + Τι λένε τα τιμολόγιά σας για το τμήμα σας, και επιλογές διαδικτυακής πληρωμής. + Ταυτότητα χρέωσης + Εκτυπώνεται σε κάθε τιμολόγιο και PDF. + Νόμιμη επωνυμία + Διεύθυνση αποστολής πληρωμών + Μητρώα + ΑΦΜ + Δευτερεύον φορολογικό μητρώο + SAM UEI + Κωδικός CAGE + Λογαριασμός ασφάλισης εργαζομένων + Υποσέλιδο τιμολογίου + Οδηγίες πληρωμής, ευχαριστίες ή νομικό κείμενο κάτω από τα σύνολα. + Λήξη συνδέσμου πληρωμής (ημέρες) + Εμφάνιση συνδέσμου «Πληρωμή online» στα τιμολόγια + Διαδικτυακές πληρωμές + Η διαδικτυακή είσπραξη (Stripe) δεν είναι ακόμη διαθέσιμη. Τα τιμολόγια πληρώνονται με επιταγή, μετρητά, μεταφορά ή κάρτα εκτός Resgrid και καταγράφονται χειροκίνητα. + Διαθεσιμότητα περιοχής + Σημαία τμήματος + Όταν διατεθεί, το τμήμα συνδέει τον δικό του λογαριασμό Stripe· το Resgrid δεν κρατά ποτέ τα χρήματα. + Διαθέσιμο + Μη διαθέσιμο σε αυτή την περιοχή + Ενεργοποιημένο + Απενεργοποιημένο + Πρόσθετο Business Operations + Μηνιαίο πρόσθετο για τμήματα που τιμολογούν πελάτες και διαχειρίζονται συμβάσεις. Μόνο το διαχειριστικό μέλος του τμήματος μπορεί να το αγοράσει ή να το ακυρώσει. + Τιμολόγηση πελατών, τιμοκατάλογοι, πληρωμές και χρονολόγηση απαιτήσεων + Τιμολόγια εργολάβων, συμβάσεις και ανάκτηση κόστους (σύντομα) + Δεδομένα μισθοδοσίας και κοστολόγηση πεδίου (σύντομα) + Το πρόσθετο είναι ενεργό για το τμήμα σας. + μήνα + Το τμήμα σας δεν διαθέτει ακόμη αυτό το πρόσθετο. + Πληρωμένο έως + Η ανανέωση ακυρώθηκε· η πρόσβαση συνεχίζεται μέχρι το τέλος της πληρωμένης περιόδου. + Αγορά + Ακύρωση ανανέωσης + Ακύρωση της ανανέωσης Business Operations; + Η χρέωση δεν είναι διαθέσιμη αυτή τη στιγμή. Δοκιμάστε ξανά αργότερα. + Διαχείριση συνδρομής + Ρυθμίσεις ενοτήτων + Σύνδεσμοι + Η κλήση δεν βρέθηκε. + Η επαφή δεν βρέθηκε. + Προσθέστε τουλάχιστον μία γραμμή πριν την αποστολή. + Τιμολόγιο με πληρωμές δεν μπορεί να ακυρωθεί. + Μόνο πρόχειρο τιμολόγιο μπορεί να τροποποιηθεί. + Το τιμολόγιο δεν βρέθηκε. + Το τιμολόγιο δεν δέχεται πληρωμή στην τρέχουσα κατάσταση. + Εξοφλημένο τιμολόγιο δεν μπορεί να ακυρωθεί. + Το τιμολόγιο είναι ακυρωμένο. + Δεν έχει οριστεί e-mail χρέωσης για τον πελάτη. + Η πληρωμή δεν βρέθηκε. + Δεν ήταν δυνατή η δημιουργία του PDF τιμολογίου. + Το προφίλ χρέωσης έχει τιμολόγια και δεν μπορεί να διαγραφεί. + Ο πελάτης χρειάζεται πρώτα ενεργό προφίλ χρέωσης. + Ο τιμοκατάλογος δεν βρέθηκε. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx new file mode 100644 index 000000000..4de3d03e5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Invoicing + Manage invoicing + View invoicing + Create, edit, send and void invoices, record payments, and manage rate cards and billing profiles. + View invoices, accounts-receivable aging and invoice PDFs. + Invoice created + Invoice sent + Invoice payment recorded + Invoice paid + Invoice overdue + Invoice voided + Invoice payment refunded + Invoice payment disputed + Billing + Invoices + invoices + Invoice + Customer invoices, payments and accounts receivable. + The Business Operations add-on is not active for this department. Existing invoices stay readable; creating or changing anything needs an active add-on. + Business Operations + Invoicing, rate cards and billing profiles. The pages also need the Business Operations add-on. + Rate Cards + Rate card + Billing rates applied when calls are added to an invoice. + A rate card lists what you charge: hourly unit or personnel time, flat fees per call, fixed fees, mileage and materials. Mark one card as the department default; a customer billing profile can pin another. + New rate card + No rate cards yet. + Rate card items + Rate card item + New item + This rate card has no items yet. + Save the rate card before adding items. + Department default (used when a customer has no pinned rate card) + Delete this rate card? + Delete this item? + Rate card deleted. + Type + Hourly – unit time on scene + Hourly – personnel time + Flat fee per call + Fixed fee + Mileage + Material + Rate + Unit label + Minimum charge + Minimum minutes + Round up to (minutes) + Rounding + Min. + round to + Unit type + Any unit type + Printed on the invoice line; defaults to the item name. + Sort order + Taxable + Active + Inactive + Default + Name + Description + Edit + Open + Add + Remove + All + Filter + Loading… + Status + Draft + Sent + Partially paid + Paid + Overdue + Void + Number + Customer + Issued + Due + Leave empty to use the profile terms: net days + Sent + Voided + Paid on + Total + Totals + Subtotal + Discount + Discount % + Tax + Amount paid + Balance + Outstanding balance + Overdue balance + New invoice + Pick the customer; the draft takes its terms, discount and rate card from the billing profile. + Select a customer… + Customers with a billing profile + Other contacts + This contact has no billing profile yet. + Set up billing profile + Choose a customer with an active billing profile. + Currency + Create draft + Edit the draft, add calls from a rate card, then preview and send. + Notes + Terms + Line items + No line items. + Qty + Rate + Amount + Add line + Add call + Lines are generated from the rate card and the call's unit time on scene; edit them before saving. + Call # + Call + Logged + No calls are linked to this customer. + On this invoice + Call lines added. Save the invoice to keep them. + Could not load the data. + The change could not be saved. + Saved. + The submitted values are not valid. + A name is required. + This needs an active Business Operations add-on. + Discount and tax are recalculated when you save. + Preview & send + Back to invoices + Terms (net days) + Tax exempt + Tax rate % + Billing profile + Where invoices go, the payment terms, tax treatment and the rate card used for this customer. + No billing profile yet. Save one to start invoicing this contact. + Open contact + Billing e-mail + Invoices are e-mailed here; defaults to the contact's e-mail. + Default discount % + Rate card + Use the department default + Purchase order number required on invoices + Active (new invoices may be created) + Billing address + Use the contact's mailing address + Address + City + State / province + Postal code + Country + Flat rate applied to taxable lines when no components are listed below. + Tax components + Up to three named taxes (for example GST and PST), each printed with its own registration number. + Rate % + Registration number + All invoices for this customer + No invoices. + Payments + No payments recorded. + Method + Check + Cash + Bank transfer / ACH + Card (outside Resgrid) + Other + Online + Payment status + Succeeded + Refunded + Partially refunded + Disputed + Dispute lost + Reference + Preview + Actions + Edit draft + Download PDF + Send invoice + Re-send invoice + E-mails the invoice PDF to the customer and marks a draft as sent. + Send to + Send + Mark as sent (no e-mail) + Mark this invoice as sent without e-mailing it? + Record payment + Manual payments only; online payments are recorded automatically. + Payment recorded. + Void invoice + A voided invoice keeps its number and cannot be edited or paid. + Reason + Invoice sent. + Invoice marked as sent. + Invoice voided. + The PDF could not be generated right now. + Aging + Accounts receivable aging + Open balances grouped by how far past due they are. + As of + Current + days past due + No outstanding invoices. + Billing settings + What your invoices say about your department, and online payment options. + Billing identity + Printed on every invoice and PDF. + Legal business name + Remit-to address + Registrations + Tax registration number + Secondary tax registration + SAM UEI + CAGE code + Workers' comp account + Invoice footer + Payment instructions, thank-you note or legal text printed under the totals. + Pay link expiry (days) + Show a "Pay online" link on invoices + Online payments + Online payment collection (Stripe) is not available yet. Invoices can be paid by check, cash, bank transfer or card outside Resgrid and recorded manually. + Region availability + Department flag + When available, your department connects its own Stripe account; Resgrid never holds the funds. + Available + Not available in this region + Enabled + Disabled + Business Operations add-on + A monthly add-on for departments that bill customers and manage contracts. Only the department's managing member can buy or cancel it. + Customer invoicing, rate cards, payments and accounts-receivable aging + Contractor rate schedules, contracts and cost recovery (coming later) + Workforce pay data and field costing (coming later) + The add-on is active for your department. + month + Your department does not hold this add-on yet. + Paid through + Renewal cancelled; access continues until the paid period ends. + Buy + Cancel renewal + Cancel the Business Operations renewal? + Billing is unavailable right now. Please try again later. + Manage subscription + Module settings + Links + The call was not found. + The contact was not found. + Add at least one line item before sending. + An invoice with payments cannot be voided. + Only a draft invoice can be edited. + The invoice was not found. + This invoice cannot take a payment in its current status. + A paid invoice cannot be voided. + This invoice is void. + No billing e-mail is set for this customer. + The payment was not found. + The invoice PDF could not be generated. + This billing profile has invoices and cannot be deleted. + The customer needs an active billing profile first. + The rate card was not found. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx new file mode 100644 index 000000000..376e7b7d6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Facturación + Gestionar facturación + Ver facturación + Crear, editar, enviar y anular facturas, registrar pagos y gestionar tarifas y perfiles de facturación. + Ver facturas, antigüedad de cuentas por cobrar y PDF de facturas. + Factura creada + Factura enviada + Pago de factura registrado + Factura pagada + Factura vencida + Factura anulada + Pago de factura reembolsado + Pago de factura disputado + Facturación + Facturas + facturas + Factura + Facturas de clientes, pagos y cuentas por cobrar. + El complemento Business Operations no está activo para este departamento. Las facturas existentes siguen siendo legibles; crear o cambiar algo requiere un complemento activo. + Business Operations + Facturación, tarifas y perfiles de facturación. Las páginas también requieren el complemento Business Operations. + Tarifas + Tarifa + Tarifas aplicadas al añadir llamadas a una factura. + Una tarifa enumera lo que cobra: tiempo por hora de unidades o personal, tarifas planas por llamada, cuotas fijas, kilometraje y materiales. Marque una como predeterminada; un perfil de facturación puede fijar otra. + Nueva tarifa + Aún no hay tarifas. + Elementos de la tarifa + Elemento de la tarifa + Nuevo elemento + Esta tarifa aún no tiene elementos. + Guarde la tarifa antes de añadir elementos. + Predeterminada del departamento (se usa cuando el cliente no tiene tarifa fija) + ¿Eliminar esta tarifa? + ¿Eliminar este elemento? + Tarifa eliminada. + Tipo + Por hora – tiempo de la unidad en el lugar + Por hora – tiempo del personal + Tarifa plana por llamada + Cuota fija + Kilometraje + Material + Tarifa + Etiqueta de unidad + Cargo mínimo + Minutos mínimos + Redondear a (minutos) + Redondeo + Mín. + redondear a + Tipo de unidad + Cualquier tipo de unidad + Se imprime en la línea de la factura; por defecto el nombre del elemento. + Orden + Imponible + Activo + Inactivo + Predeterminada + Nombre + Descripción + Editar + Abrir + Añadir + Quitar + Todas + Filtrar + Cargando… + Estado + Borrador + Enviada + Pagada parcialmente + Pagada + Vencida + Anulada + Número + Cliente + Emitida + Vencimiento + Déjelo vacío para usar los términos del perfil: días netos + Enviada el + Anulada el + Pagada el + Total + Totales + Subtotal + Descuento + Descuento % + Impuesto + Importe pagado + Saldo + Saldo pendiente + Saldo vencido + Nueva factura + Elija el cliente; el borrador toma los términos, el descuento y la tarifa del perfil de facturación. + Seleccione un cliente… + Clientes con perfil de facturación + Otros contactos + Este contacto aún no tiene perfil de facturación. + Configurar perfil de facturación + Elija un cliente con un perfil de facturación activo. + Moneda + Crear borrador + Edite el borrador, añada llamadas desde una tarifa y luego previsualice y envíe. + Notas + Condiciones + Líneas + Sin líneas. + Cant. + Tarifa + Importe + Añadir línea + Añadir llamada + Las líneas se generan a partir de la tarifa y el tiempo de las unidades en el lugar; edítelas antes de guardar. + N.º de llamada + Llamada + Registrada + No hay llamadas vinculadas a este cliente. + Ya en esta factura + Líneas de la llamada añadidas. Guarde la factura para conservarlas. + No se pudieron cargar los datos. + No se pudo guardar el cambio. + Guardado. + Los valores enviados no son válidos. + Se requiere un nombre. + Esto requiere un complemento Business Operations activo. + El descuento y el impuesto se recalculan al guardar. + Previsualizar y enviar + Volver a las facturas + Condiciones (días netos) + Exento de impuestos + Tasa de impuesto % + Perfil de facturación + Adónde van las facturas, los términos de pago, el tratamiento fiscal y la tarifa de este cliente. + Aún no hay perfil de facturación. Guarde uno para empezar a facturar a este contacto. + Abrir contacto + Correo de facturación + Las facturas se envían aquí; por defecto el correo del contacto. + Descuento predeterminado % + Tarifa + Usar la predeterminada del departamento + Se requiere número de orden de compra en las facturas + Activo (se pueden crear nuevas facturas) + Dirección de facturación + Usar la dirección postal del contacto + Dirección + Ciudad + Estado / provincia + Código postal + País + Tasa única aplicada a las líneas imponibles cuando no hay componentes abajo. + Componentes de impuesto + Hasta tres impuestos con nombre (por ejemplo GST y PST), cada uno impreso con su número de registro. + Tasa % + Número de registro + Todas las facturas de este cliente + Sin facturas. + Pagos + No hay pagos registrados. + Método + Cheque + Efectivo + Transferencia / ACH + Tarjeta (fuera de Resgrid) + Otro + En línea + Estado del pago + Correcto + Reembolsado + Reembolsado parcialmente + En disputa + Disputa perdida + Referencia + Vista previa + Acciones + Editar borrador + Descargar PDF + Enviar factura + Reenviar factura + Envía el PDF de la factura por correo al cliente y marca el borrador como enviado. + Enviar a + Enviar + Marcar como enviada (sin correo) + ¿Marcar esta factura como enviada sin enviarla por correo? + Registrar pago + Solo pagos manuales; los pagos en línea se registran automáticamente. + Pago registrado. + Anular factura + Una factura anulada conserva su número y no se puede editar ni pagar. + Motivo + Factura enviada. + Factura marcada como enviada. + Factura anulada. + No se pudo generar el PDF en este momento. + Antigüedad + Antigüedad de cuentas por cobrar + Saldos abiertos agrupados según el tiempo vencido. + A fecha de + Vigente + días vencidos + No hay facturas pendientes. + Configuración de facturación + Lo que sus facturas dicen sobre su departamento y las opciones de pago en línea. + Identidad de facturación + Se imprime en cada factura y PDF. + Razón social + Dirección de remisión + Registros + Número de registro fiscal + Registro fiscal secundario + SAM UEI + Código CAGE + Cuenta de compensación laboral + Pie de la factura + Instrucciones de pago, agradecimiento o texto legal impreso bajo los totales. + Caducidad del enlace de pago (días) + Mostrar un enlace "Pagar en línea" en las facturas + Pagos en línea + El cobro en línea (Stripe) aún no está disponible. Las facturas pueden pagarse con cheque, efectivo, transferencia o tarjeta fuera de Resgrid y registrarse manualmente. + Disponibilidad en la región + Indicador del departamento + Cuando esté disponible, su departamento conectará su propia cuenta de Stripe; Resgrid nunca retiene los fondos. + Disponible + No disponible en esta región + Habilitado + Deshabilitado + Complemento Business Operations + Un complemento mensual para departamentos que facturan a clientes y gestionan contratos. Solo el miembro administrador del departamento puede comprarlo o cancelarlo. + Facturación de clientes, tarifas, pagos y antigüedad de cuentas por cobrar + Tablas de tarifas de contratistas, contratos y recuperación de costes (próximamente) + Datos de nómina y costes de campo (próximamente) + El complemento está activo para su departamento. + mes + Su departamento aún no tiene este complemento. + Pagado hasta + Renovación cancelada; el acceso continúa hasta el final del periodo pagado. + Comprar + Cancelar renovación + ¿Cancelar la renovación de Business Operations? + La facturación no está disponible en este momento. Inténtelo más tarde. + Gestionar suscripción + Configuración de módulos + Enlaces + No se encontró la llamada. + No se encontró el contacto. + Añada al menos una línea antes de enviar. + Una factura con pagos no se puede anular. + Solo se puede editar un borrador de factura. + No se encontró la factura. + Esta factura no puede recibir un pago en su estado actual. + Una factura pagada no se puede anular. + Esta factura está anulada. + No hay correo de facturación para este cliente. + No se encontró el pago. + No se pudo generar el PDF de la factura. + Este perfil de facturación tiene facturas y no se puede eliminar. + El cliente necesita primero un perfil de facturación activo. + No se encontró la tarifa. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx new file mode 100644 index 000000000..e731b8ba3 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Facturation + Gérer la facturation + Consulter la facturation + Créer, modifier, envoyer et annuler des factures, enregistrer des paiements et gérer les grilles tarifaires et les profils de facturation. + Consulter les factures, la balance âgée des comptes clients et les PDF de factures. + Facture créée + Facture envoyée + Paiement de facture enregistré + Facture payée + Facture en retard + Facture annulée + Paiement de facture remboursé + Paiement de facture contesté + Facturation + Factures + factures + Facture + Factures clients, paiements et comptes clients. + Le module complémentaire Business Operations n'est pas actif pour ce service. Les factures existantes restent consultables ; toute création ou modification nécessite un module actif. + Business Operations + Facturation, grilles tarifaires et profils de facturation. Les pages nécessitent aussi le module Business Operations. + Grilles tarifaires + Grille tarifaire + Tarifs appliqués lorsque des interventions sont ajoutées à une facture. + Une grille tarifaire liste vos tarifs : temps horaire des unités ou du personnel, forfaits par intervention, frais fixes, kilométrage et fournitures. Marquez une grille par défaut ; un profil de facturation peut en imposer une autre. + Nouvelle grille tarifaire + Aucune grille tarifaire pour le moment. + Lignes de la grille + Ligne de la grille + Nouvelle ligne + Cette grille ne contient encore aucune ligne. + Enregistrez la grille avant d'ajouter des lignes. + Grille par défaut du service (utilisée quand le client n'en a pas d'imposée) + Supprimer cette grille tarifaire ? + Supprimer cette ligne ? + Grille tarifaire supprimée. + Type + Horaire – temps de l'unité sur place + Horaire – temps du personnel + Forfait par intervention + Frais fixes + Kilométrage + Fourniture + Tarif + Libellé d'unité + Montant minimum + Minutes minimum + Arrondir à (minutes) + Arrondi + Min. + arrondi à + Type d'unité + Tout type d'unité + Imprimé sur la ligne de facture ; par défaut le nom de la ligne. + Ordre de tri + Taxable + Actif + Inactif + Par défaut + Nom + Description + Modifier + Ouvrir + Ajouter + Retirer + Toutes + Filtrer + Chargement… + Statut + Brouillon + Envoyée + Partiellement payée + Payée + En retard + Annulée + Numéro + Client + Émise le + Échéance + Laisser vide pour appliquer les conditions du profil : jours nets + Envoyée le + Annulée le + Payée le + Total + Totaux + Sous-total + Remise + Remise % + Taxe + Montant payé + Solde + Solde impayé + Solde en retard + Nouvelle facture + Choisissez le client ; le brouillon reprend les conditions, la remise et la grille du profil de facturation. + Sélectionner un client… + Clients avec profil de facturation + Autres contacts + Ce contact n'a pas encore de profil de facturation. + Configurer le profil de facturation + Choisissez un client avec un profil de facturation actif. + Devise + Créer le brouillon + Modifiez le brouillon, ajoutez des interventions depuis une grille, puis prévisualisez et envoyez. + Notes + Conditions + Lignes + Aucune ligne. + Qté + Tarif + Montant + Ajouter une ligne + Ajouter une intervention + Les lignes sont générées depuis la grille et le temps des unités sur place ; modifiez-les avant d'enregistrer. + N° d'intervention + Intervention + Enregistrée + Aucune intervention n'est liée à ce client. + Déjà sur cette facture + Lignes ajoutées. Enregistrez la facture pour les conserver. + Impossible de charger les données. + La modification n'a pas pu être enregistrée. + Enregistré. + Les valeurs saisies ne sont pas valides. + Un nom est requis. + Cette action nécessite un module Business Operations actif. + La remise et la taxe sont recalculées à l'enregistrement. + Prévisualiser et envoyer + Retour aux factures + Conditions (jours nets) + Exonéré de taxe + Taux de taxe % + Profil de facturation + Destination des factures, conditions de paiement, traitement fiscal et grille tarifaire de ce client. + Pas encore de profil de facturation. Enregistrez-en un pour facturer ce contact. + Ouvrir le contact + E-mail de facturation + Les factures sont envoyées ici ; par défaut l'e-mail du contact. + Remise par défaut % + Grille tarifaire + Utiliser la grille par défaut du service + Numéro de bon de commande requis sur les factures + Actif (de nouvelles factures peuvent être créées) + Adresse de facturation + Utiliser l'adresse postale du contact + Adresse + Ville + État / province + Code postal + Pays + Taux unique appliqué aux lignes taxables si aucun composant n'est listé ci-dessous. + Composants de taxe + Jusqu'à trois taxes nommées (par exemple TPS et TVQ), chacune imprimée avec son numéro d'enregistrement. + Taux % + Numéro d'enregistrement + Toutes les factures de ce client + Aucune facture. + Paiements + Aucun paiement enregistré. + Mode + Chèque + Espèces + Virement / ACH + Carte (hors Resgrid) + Autre + En ligne + Statut du paiement + Réussi + Remboursé + Partiellement remboursé + Contesté + Litige perdu + Référence + Aperçu + Actions + Modifier le brouillon + Télécharger le PDF + Envoyer la facture + Renvoyer la facture + Envoie le PDF de la facture par e-mail au client et marque un brouillon comme envoyé. + Envoyer à + Envoyer + Marquer comme envoyée (sans e-mail) + Marquer cette facture comme envoyée sans l'envoyer par e-mail ? + Enregistrer un paiement + Paiements manuels uniquement ; les paiements en ligne sont enregistrés automatiquement. + Paiement enregistré. + Annuler la facture + Une facture annulée conserve son numéro et ne peut plus être modifiée ni payée. + Motif + Facture envoyée. + Facture marquée comme envoyée. + Facture annulée. + Le PDF n'a pas pu être généré pour le moment. + Balance âgée + Balance âgée des comptes clients + Soldes ouverts regroupés selon leur retard. + Au + Non échu + jours de retard + Aucune facture impayée. + Paramètres de facturation + Ce que vos factures indiquent sur votre service, et les options de paiement en ligne. + Identité de facturation + Imprimé sur chaque facture et PDF. + Raison sociale + Adresse de règlement + Immatriculations + Numéro d'identification fiscale + Immatriculation fiscale secondaire + SAM UEI + Code CAGE + Compte accidents du travail + Pied de facture + Instructions de paiement, remerciement ou mention légale sous les totaux. + Expiration du lien de paiement (jours) + Afficher un lien « Payer en ligne » sur les factures + Paiements en ligne + L'encaissement en ligne (Stripe) n'est pas encore disponible. Les factures peuvent être réglées par chèque, espèces, virement ou carte hors Resgrid et enregistrées manuellement. + Disponibilité dans la région + Indicateur du service + Une fois disponible, votre service connectera son propre compte Stripe ; Resgrid ne détient jamais les fonds. + Disponible + Non disponible dans cette région + Activé + Désactivé + Module Business Operations + Un module mensuel pour les services qui facturent des clients et gèrent des contrats. Seul le membre gestionnaire du service peut l'acheter ou le résilier. + Facturation clients, grilles tarifaires, paiements et balance âgée + Barèmes de sous-traitance, contrats et recouvrement des coûts (à venir) + Données de paie et chiffrage terrain (à venir) + Le module est actif pour votre service. + mois + Votre service ne possède pas encore ce module. + Payé jusqu'au + Renouvellement annulé ; l'accès se poursuit jusqu'à la fin de la période payée. + Acheter + Annuler le renouvellement + Annuler le renouvellement de Business Operations ? + La facturation est indisponible pour le moment. Réessayez plus tard. + Gérer l'abonnement + Paramètres des modules + Liens + L'intervention est introuvable. + Le contact est introuvable. + Ajoutez au moins une ligne avant l'envoi. + Une facture avec des paiements ne peut pas être annulée. + Seul un brouillon de facture peut être modifié. + La facture est introuvable. + Cette facture ne peut pas recevoir de paiement dans son statut actuel. + Une facture payée ne peut pas être annulée. + Cette facture est annulée. + Aucun e-mail de facturation n'est défini pour ce client. + Le paiement est introuvable. + Le PDF de la facture n'a pas pu être généré. + Ce profil de facturation a des factures et ne peut pas être supprimé. + Le client doit d'abord avoir un profil de facturation actif. + La grille tarifaire est introuvable. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx new file mode 100644 index 000000000..3d9c2252a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Fatturazione + Gestire la fatturazione + Visualizzare la fatturazione + Creare, modificare, inviare e annullare fatture, registrare pagamenti e gestire listini e profili di fatturazione. + Visualizzare fatture, scadenzario crediti e PDF delle fatture. + Fattura creata + Fattura inviata + Pagamento fattura registrato + Fattura pagata + Fattura scaduta + Fattura annullata + Pagamento fattura rimborsato + Pagamento fattura contestato + Fatturazione + Fatture + fatture + Fattura + Fatture ai clienti, pagamenti e crediti. + Il componente aggiuntivo Business Operations non è attivo per questo dipartimento. Le fatture esistenti restano leggibili; creare o modificare richiede un componente attivo. + Business Operations + Fatturazione, listini e profili di fatturazione. Le pagine richiedono anche il componente Business Operations. + Listini + Listino + Tariffe applicate quando le chiamate vengono aggiunte a una fattura. + Un listino elenca le tariffe: tempo orario di unità o personale, importi fissi per chiamata, canoni, chilometraggio e materiali. Segna un listino come predefinito; un profilo di fatturazione può fissarne un altro. + Nuovo listino + Nessun listino ancora. + Voci del listino + Voce del listino + Nuova voce + Questo listino non ha ancora voci. + Salva il listino prima di aggiungere voci. + Predefinito del dipartimento (usato quando il cliente non ha un listino fissato) + Eliminare questo listino? + Eliminare questa voce? + Listino eliminato. + Tipo + Orario – tempo dell'unità sul posto + Orario – tempo del personale + Importo fisso per chiamata + Canone fisso + Chilometraggio + Materiale + Tariffa + Etichetta unità + Addebito minimo + Minuti minimi + Arrotonda a (minuti) + Arrotondamento + Min. + arrotonda a + Tipo di unità + Qualsiasi tipo di unità + Stampato sulla riga della fattura; per impostazione predefinita il nome della voce. + Ordinamento + Imponibile + Attivo + Inattivo + Predefinito + Nome + Descrizione + Modifica + Apri + Aggiungi + Rimuovi + Tutte + Filtra + Caricamento… + Stato + Bozza + Inviata + Parzialmente pagata + Pagata + Scaduta + Annullata + Numero + Cliente + Emessa + Scadenza + Lascia vuoto per usare i termini del profilo: giorni netti + Inviata il + Annullata il + Pagata il + Totale + Totali + Subtotale + Sconto + Sconto % + Imposta + Importo pagato + Saldo + Saldo in sospeso + Saldo scaduto + Nuova fattura + Scegli il cliente; la bozza prende termini, sconto e listino dal profilo di fatturazione. + Seleziona un cliente… + Clienti con profilo di fatturazione + Altri contatti + Questo contatto non ha ancora un profilo di fatturazione. + Configura il profilo di fatturazione + Scegli un cliente con un profilo di fatturazione attivo. + Valuta + Crea bozza + Modifica la bozza, aggiungi chiamate da un listino, poi visualizza l'anteprima e invia. + Note + Termini + Righe + Nessuna riga. + Qtà + Tariffa + Importo + Aggiungi riga + Aggiungi chiamata + Le righe sono generate dal listino e dal tempo delle unità sul posto; modificale prima di salvare. + N. chiamata + Chiamata + Registrata + Nessuna chiamata collegata a questo cliente. + Già in questa fattura + Righe della chiamata aggiunte. Salva la fattura per mantenerle. + Impossibile caricare i dati. + Impossibile salvare la modifica. + Salvato. + I valori inviati non sono validi. + Il nome è obbligatorio. + Richiede un componente Business Operations attivo. + Sconto e imposta vengono ricalcolati al salvataggio. + Anteprima e invio + Torna alle fatture + Termini (giorni netti) + Esente da imposta + Aliquota % + Profilo di fatturazione + Dove vanno le fatture, i termini di pagamento, il trattamento fiscale e il listino di questo cliente. + Nessun profilo di fatturazione. Salvane uno per iniziare a fatturare questo contatto. + Apri contatto + E-mail di fatturazione + Le fatture vengono inviate qui; predefinita l'e-mail del contatto. + Sconto predefinito % + Listino + Usa il predefinito del dipartimento + Numero d'ordine obbligatorio sulle fatture + Attivo (è possibile creare nuove fatture) + Indirizzo di fatturazione + Usa l'indirizzo postale del contatto + Indirizzo + Città + Provincia + CAP + Paese + Aliquota unica applicata alle righe imponibili quando non sono elencati componenti. + Componenti d'imposta + Fino a tre imposte denominate (ad esempio GST e PST), ciascuna stampata con il proprio numero di registrazione. + Aliquota % + Numero di registrazione + Tutte le fatture di questo cliente + Nessuna fattura. + Pagamenti + Nessun pagamento registrato. + Metodo + Assegno + Contanti + Bonifico / ACH + Carta (fuori da Resgrid) + Altro + Online + Stato del pagamento + Riuscito + Rimborsato + Parzialmente rimborsato + Contestato + Contestazione persa + Riferimento + Anteprima + Azioni + Modifica bozza + Scarica PDF + Invia fattura + Invia di nuovo la fattura + Invia il PDF della fattura via e-mail al cliente e segna la bozza come inviata. + Invia a + Invia + Segna come inviata (senza e-mail) + Segnare questa fattura come inviata senza inviarla via e-mail? + Registra pagamento + Solo pagamenti manuali; i pagamenti online vengono registrati automaticamente. + Pagamento registrato. + Annulla fattura + Una fattura annullata mantiene il numero e non può essere modificata o pagata. + Motivo + Fattura inviata. + Fattura segnata come inviata. + Fattura annullata. + Impossibile generare il PDF al momento. + Scadenzario + Scadenzario crediti + Saldi aperti raggruppati per giorni di ritardo. + Al + Corrente + giorni di ritardo + Nessuna fattura in sospeso. + Impostazioni di fatturazione + Cosa dicono le fatture del tuo dipartimento e le opzioni di pagamento online. + Identità di fatturazione + Stampato su ogni fattura e PDF. + Ragione sociale + Indirizzo per il pagamento + Registrazioni + Partita IVA + Registrazione fiscale secondaria + SAM UEI + Codice CAGE + Conto assicurazione infortuni + Piè di pagina fattura + Istruzioni di pagamento, ringraziamento o testo legale sotto i totali. + Scadenza link di pagamento (giorni) + Mostra un link "Paga online" sulle fatture + Pagamenti online + L'incasso online (Stripe) non è ancora disponibile. Le fatture possono essere pagate con assegno, contanti, bonifico o carta fuori da Resgrid e registrate manualmente. + Disponibilità nella regione + Flag del dipartimento + Quando disponibile, il dipartimento collega il proprio account Stripe; Resgrid non trattiene mai i fondi. + Disponibile + Non disponibile in questa regione + Abilitato + Disabilitato + Componente Business Operations + Un componente mensile per i dipartimenti che fatturano ai clienti e gestiscono contratti. Solo il membro amministratore può acquistarlo o annullarlo. + Fatturazione clienti, listini, pagamenti e scadenzario crediti + Tariffari per appaltatori, contratti e recupero costi (in arrivo) + Dati retributivi e costi operativi (in arrivo) + Il componente è attivo per il tuo dipartimento. + mese + Il tuo dipartimento non ha ancora questo componente. + Pagato fino al + Rinnovo annullato; l'accesso continua fino alla fine del periodo pagato. + Acquista + Annulla rinnovo + Annullare il rinnovo di Business Operations? + La fatturazione non è disponibile al momento. Riprova più tardi. + Gestisci abbonamento + Impostazioni moduli + Collegamenti + Chiamata non trovata. + Contatto non trovato. + Aggiungi almeno una riga prima di inviare. + Una fattura con pagamenti non può essere annullata. + Solo una bozza di fattura può essere modificata. + Fattura non trovata. + Questa fattura non può ricevere un pagamento nello stato attuale. + Una fattura pagata non può essere annullata. + Questa fattura è annullata. + Nessuna e-mail di fatturazione impostata per questo cliente. + Pagamento non trovato. + Impossibile generare il PDF della fattura. + Questo profilo di fatturazione ha fatture e non può essere eliminato. + Il cliente ha bisogno prima di un profilo di fatturazione attivo. + Listino non trovato. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx new file mode 100644 index 000000000..af5cee725 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Fakturowanie + Zarządzaj fakturowaniem + Przeglądaj fakturowanie + Tworzenie, edycja, wysyłanie i anulowanie faktur, rejestrowanie płatności oraz zarządzanie cennikami i profilami rozliczeniowymi. + Przeglądanie faktur, wiekowania należności i plików PDF faktur. + Faktura utworzona + Faktura wysłana + Płatność za fakturę zarejestrowana + Faktura opłacona + Faktura przeterminowana + Faktura anulowana + Płatność za fakturę zwrócona + Płatność za fakturę zakwestionowana + Rozliczenia + Faktury + faktur + Faktura + Faktury klientów, płatności i należności. + Dodatek Business Operations nie jest aktywny dla tego oddziału. Istniejące faktury pozostają do odczytu; tworzenie lub zmiany wymagają aktywnego dodatku. + Business Operations + Fakturowanie, cenniki i profile rozliczeniowe. Strony wymagają też dodatku Business Operations. + Cenniki + Cennik + Stawki stosowane przy dodawaniu zgłoszeń do faktury. + Cennik zawiera stawki: godzinowe dla jednostek lub personelu, ryczałt za zgłoszenie, opłaty stałe, kilometry i materiały. Oznacz jeden cennik jako domyślny; profil rozliczeniowy może wskazać inny. + Nowy cennik + Brak cenników. + Pozycje cennika + Pozycja cennika + Nowa pozycja + Ten cennik nie ma jeszcze pozycji. + Zapisz cennik przed dodaniem pozycji. + Domyślny dla oddziału (używany, gdy klient nie ma przypiętego cennika) + Usunąć ten cennik? + Usunąć tę pozycję? + Cennik usunięty. + Typ + Godzinowo – czas jednostki na miejscu + Godzinowo – czas personelu + Ryczałt za zgłoszenie + Opłata stała + Kilometry + Materiał + Stawka + Etykieta jednostki + Opłata minimalna + Minimalna liczba minut + Zaokrąglaj do (minuty) + Zaokrąglanie + Min. + zaokrąglij do + Typ jednostki + Dowolny typ jednostki + Drukowane w wierszu faktury; domyślnie nazwa pozycji. + Kolejność + Opodatkowane + Aktywny + Nieaktywny + Domyślny + Nazwa + Opis + Edytuj + Otwórz + Dodaj + Usuń + Wszystkie + Filtruj + Ładowanie… + Status + Wersja robocza + Wysłana + Częściowo opłacona + Opłacona + Przeterminowana + Anulowana + Numer + Klient + Wystawiono + Termin + Pozostaw puste, aby użyć warunków profilu: dni netto + Wysłano + Anulowano + Data płatności + Razem + Sumy + Suma częściowa + Rabat + Rabat % + Podatek + Kwota zapłacona + Saldo + Saldo należności + Saldo przeterminowany + Nowa faktura + Wybierz klienta; wersja robocza pobiera warunki, rabat i cennik z profilu rozliczeniowego. + Wybierz klienta… + Klienci z profilem rozliczeniowym + Inne kontakty + Ten kontakt nie ma jeszcze profilu rozliczeniowego. + Skonfiguruj profil rozliczeniowy + Wybierz klienta z aktywnym profilem rozliczeniowym. + Waluta + Utwórz wersję roboczą + Edytuj wersję roboczą, dodaj zgłoszenia z cennika, a następnie podejrzyj i wyślij. + Uwagi + Warunki + Pozycje + Brak pozycji. + Ilość + Stawka + Kwota + Dodaj pozycję + Dodaj zgłoszenie + Pozycje są generowane z cennika i czasu jednostek na miejscu; edytuj je przed zapisaniem. + Nr zgłoszenia + Zgłoszenie + Zarejestrowano + Brak zgłoszeń powiązanych z tym klientem. + Już na tej fakturze + Dodano pozycje zgłoszenia. Zapisz fakturę, aby je zachować. + Nie udało się wczytać danych. + Nie udało się zapisać zmiany. + Zapisano. + Przesłane wartości są nieprawidłowe. + Nazwa jest wymagana. + Wymaga aktywnego dodatku Business Operations. + Rabat i podatek są przeliczane przy zapisie. + Podgląd i wysyłka + Wróć do faktur + Warunki (dni netto) + Zwolniony z podatku + Stawka podatku % + Profil rozliczeniowy + Gdzie trafiają faktury, warunki płatności, podatki i cennik dla tego klienta. + Brak profilu rozliczeniowego. Zapisz go, aby zacząć fakturować ten kontakt. + Otwórz kontakt + E-mail do faktur + Faktury są wysyłane na ten adres; domyślnie e-mail kontaktu. + Domyślny rabat % + Cennik + Użyj domyślnego oddziału + Numer zamówienia wymagany na fakturach + Aktywny (można tworzyć nowe faktury) + Adres do faktur + Użyj adresu korespondencyjnego kontaktu + Adres + Miasto + Województwo + Kod pocztowy + Kraj + Stawka ryczałtowa dla pozycji opodatkowanych, gdy poniżej nie podano składników. + Składniki podatku + Do trzech nazwanych podatków (np. GST i PST), każdy drukowany z własnym numerem rejestracji. + Stawka % + Numer rejestracji + Wszystkie faktury tego klienta + Brak faktur. + Płatności + Brak zarejestrowanych płatności. + Metoda + Czek + Gotówka + Przelew / ACH + Karta (poza Resgrid) + Inne + Online + Status płatności + Zrealizowana + Zwrócona + Częściowo zwrócona + Zakwestionowana + Spór przegrany + Referencja + Podgląd + Akcje + Edytuj wersję roboczą + Pobierz PDF + Wyślij fakturę + Wyślij fakturę ponownie + Wysyła PDF faktury e-mailem do klienta i oznacza wersję roboczą jako wysłaną. + Wyślij do + Wyślij + Oznacz jako wysłaną (bez e-maila) + Oznaczyć tę fakturę jako wysłaną bez wysyłania e-maila? + Zarejestruj płatność + Tylko płatności ręczne; płatności online są rejestrowane automatycznie. + Płatność zarejestrowana. + Anuluj fakturę + Anulowana faktura zachowuje numer i nie można jej edytować ani opłacić. + Powód + Faktura wysłana. + Faktura oznaczona jako wysłana. + Faktura anulowana. + Nie można teraz wygenerować PDF. + Wiekowanie + Wiekowanie należności + Otwarte salda pogrupowane według przeterminowania. + Na dzień + Bieżące + dni po terminie + Brak nieopłaconych faktur. + Ustawienia rozliczeń + Co faktury mówią o Twoim oddziale oraz opcje płatności online. + Dane rozliczeniowe + Drukowane na każdej fakturze i PDF. + Nazwa prawna + Adres do wpłat + Rejestracje + Numer identyfikacji podatkowej + Dodatkowy numer podatkowy + SAM UEI + Kod CAGE + Konto ubezpieczenia pracowniczego + Stopka faktury + Instrukcje płatności, podziękowanie lub tekst prawny pod sumami. + Ważność linku płatności (dni) + Pokaż link „Zapłać online” na fakturach + Płatności online + Pobieranie płatności online (Stripe) nie jest jeszcze dostępne. Faktury można opłacać czekiem, gotówką, przelewem lub kartą poza Resgrid i rejestrować ręcznie. + Dostępność w regionie + Flaga oddziału + Gdy będzie dostępne, oddział połączy własne konto Stripe; Resgrid nigdy nie przechowuje środków. + Dostępne + Niedostępne w tym regionie + Włączone + Wyłączone + Dodatek Business Operations + Miesięczny dodatek dla oddziałów, które fakturują klientów i zarządzają umowami. Tylko członek zarządzający oddziałem może go kupić lub anulować. + Fakturowanie klientów, cenniki, płatności i wiekowanie należności + Stawki wykonawców, umowy i odzyskiwanie kosztów (wkrótce) + Dane płacowe i koszty operacyjne (wkrótce) + Dodatek jest aktywny dla Twojego oddziału. + miesiąc + Twój oddział nie ma jeszcze tego dodatku. + Opłacone do + Odnowienie anulowane; dostęp trwa do końca opłaconego okresu. + Kup + Anuluj odnowienie + Anulować odnowienie Business Operations? + Rozliczenia są teraz niedostępne. Spróbuj ponownie później. + Zarządzaj subskrypcją + Ustawienia modułów + Łącza + Nie znaleziono zgłoszenia. + Nie znaleziono kontaktu. + Dodaj co najmniej jedną pozycję przed wysłaniem. + Nie można anulować faktury z płatnościami. + Można edytować tylko wersję roboczą faktury. + Nie znaleziono faktury. + Ta faktura nie może przyjąć płatności w obecnym stanie. + Nie można anulować opłaconej faktury. + Ta faktura jest anulowana. + Brak e-maila do faktur dla tego klienta. + Nie znaleziono płatności. + Nie można wygenerować PDF faktury. + Ten profil rozliczeniowy ma faktury i nie można go usunąć. + Klient musi najpierw mieć aktywny profil rozliczeniowy. + Nie znaleziono cennika. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx new file mode 100644 index 000000000..4de3d03e5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Invoicing + Manage invoicing + View invoicing + Create, edit, send and void invoices, record payments, and manage rate cards and billing profiles. + View invoices, accounts-receivable aging and invoice PDFs. + Invoice created + Invoice sent + Invoice payment recorded + Invoice paid + Invoice overdue + Invoice voided + Invoice payment refunded + Invoice payment disputed + Billing + Invoices + invoices + Invoice + Customer invoices, payments and accounts receivable. + The Business Operations add-on is not active for this department. Existing invoices stay readable; creating or changing anything needs an active add-on. + Business Operations + Invoicing, rate cards and billing profiles. The pages also need the Business Operations add-on. + Rate Cards + Rate card + Billing rates applied when calls are added to an invoice. + A rate card lists what you charge: hourly unit or personnel time, flat fees per call, fixed fees, mileage and materials. Mark one card as the department default; a customer billing profile can pin another. + New rate card + No rate cards yet. + Rate card items + Rate card item + New item + This rate card has no items yet. + Save the rate card before adding items. + Department default (used when a customer has no pinned rate card) + Delete this rate card? + Delete this item? + Rate card deleted. + Type + Hourly – unit time on scene + Hourly – personnel time + Flat fee per call + Fixed fee + Mileage + Material + Rate + Unit label + Minimum charge + Minimum minutes + Round up to (minutes) + Rounding + Min. + round to + Unit type + Any unit type + Printed on the invoice line; defaults to the item name. + Sort order + Taxable + Active + Inactive + Default + Name + Description + Edit + Open + Add + Remove + All + Filter + Loading… + Status + Draft + Sent + Partially paid + Paid + Overdue + Void + Number + Customer + Issued + Due + Leave empty to use the profile terms: net days + Sent + Voided + Paid on + Total + Totals + Subtotal + Discount + Discount % + Tax + Amount paid + Balance + Outstanding balance + Overdue balance + New invoice + Pick the customer; the draft takes its terms, discount and rate card from the billing profile. + Select a customer… + Customers with a billing profile + Other contacts + This contact has no billing profile yet. + Set up billing profile + Choose a customer with an active billing profile. + Currency + Create draft + Edit the draft, add calls from a rate card, then preview and send. + Notes + Terms + Line items + No line items. + Qty + Rate + Amount + Add line + Add call + Lines are generated from the rate card and the call's unit time on scene; edit them before saving. + Call # + Call + Logged + No calls are linked to this customer. + On this invoice + Call lines added. Save the invoice to keep them. + Could not load the data. + The change could not be saved. + Saved. + The submitted values are not valid. + A name is required. + This needs an active Business Operations add-on. + Discount and tax are recalculated when you save. + Preview & send + Back to invoices + Terms (net days) + Tax exempt + Tax rate % + Billing profile + Where invoices go, the payment terms, tax treatment and the rate card used for this customer. + No billing profile yet. Save one to start invoicing this contact. + Open contact + Billing e-mail + Invoices are e-mailed here; defaults to the contact's e-mail. + Default discount % + Rate card + Use the department default + Purchase order number required on invoices + Active (new invoices may be created) + Billing address + Use the contact's mailing address + Address + City + State / province + Postal code + Country + Flat rate applied to taxable lines when no components are listed below. + Tax components + Up to three named taxes (for example GST and PST), each printed with its own registration number. + Rate % + Registration number + All invoices for this customer + No invoices. + Payments + No payments recorded. + Method + Check + Cash + Bank transfer / ACH + Card (outside Resgrid) + Other + Online + Payment status + Succeeded + Refunded + Partially refunded + Disputed + Dispute lost + Reference + Preview + Actions + Edit draft + Download PDF + Send invoice + Re-send invoice + E-mails the invoice PDF to the customer and marks a draft as sent. + Send to + Send + Mark as sent (no e-mail) + Mark this invoice as sent without e-mailing it? + Record payment + Manual payments only; online payments are recorded automatically. + Payment recorded. + Void invoice + A voided invoice keeps its number and cannot be edited or paid. + Reason + Invoice sent. + Invoice marked as sent. + Invoice voided. + The PDF could not be generated right now. + Aging + Accounts receivable aging + Open balances grouped by how far past due they are. + As of + Current + days past due + No outstanding invoices. + Billing settings + What your invoices say about your department, and online payment options. + Billing identity + Printed on every invoice and PDF. + Legal business name + Remit-to address + Registrations + Tax registration number + Secondary tax registration + SAM UEI + CAGE code + Workers' comp account + Invoice footer + Payment instructions, thank-you note or legal text printed under the totals. + Pay link expiry (days) + Show a "Pay online" link on invoices + Online payments + Online payment collection (Stripe) is not available yet. Invoices can be paid by check, cash, bank transfer or card outside Resgrid and recorded manually. + Region availability + Department flag + When available, your department connects its own Stripe account; Resgrid never holds the funds. + Available + Not available in this region + Enabled + Disabled + Business Operations add-on + A monthly add-on for departments that bill customers and manage contracts. Only the department's managing member can buy or cancel it. + Customer invoicing, rate cards, payments and accounts-receivable aging + Contractor rate schedules, contracts and cost recovery (coming later) + Workforce pay data and field costing (coming later) + The add-on is active for your department. + month + Your department does not hold this add-on yet. + Paid through + Renewal cancelled; access continues until the paid period ends. + Buy + Cancel renewal + Cancel the Business Operations renewal? + Billing is unavailable right now. Please try again later. + Manage subscription + Module settings + Links + The call was not found. + The contact was not found. + Add at least one line item before sending. + An invoice with payments cannot be voided. + Only a draft invoice can be edited. + The invoice was not found. + This invoice cannot take a payment in its current status. + A paid invoice cannot be voided. + This invoice is void. + No billing e-mail is set for this customer. + The payment was not found. + The invoice PDF could not be generated. + This billing profile has invoices and cannot be deleted. + The customer needs an active billing profile first. + The rate card was not found. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx new file mode 100644 index 000000000..ef041ad6d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Fakturering + Hantera fakturering + Visa fakturering + Skapa, redigera, skicka och makulera fakturor, registrera betalningar samt hantera prislistor och faktureringsprofiler. + Visa fakturor, kundfordringars åldersanalys och faktura-PDF:er. + Faktura skapad + Faktura skickad + Fakturabetalning registrerad + Faktura betald + Faktura förfallen + Faktura makulerad + Fakturabetalning återbetald + Fakturabetalning bestriden + Fakturering + Fakturor + fakturor + Faktura + Kundfakturor, betalningar och kundfordringar. + Tillägget Business Operations är inte aktivt för denna avdelning. Befintliga fakturor kan fortfarande läsas; att skapa eller ändra kräver ett aktivt tillägg. + Business Operations + Fakturering, prislistor och faktureringsprofiler. Sidorna kräver även tillägget Business Operations. + Prislistor + Prislista + Debiteringssatser som tillämpas när larm läggs till på en faktura. + En prislista anger vad ni debiterar: timtid för enheter eller personal, fast avgift per larm, fasta avgifter, körsträcka och material. Markera en lista som standard; en faktureringsprofil kan välja en annan. + Ny prislista + Inga prislistor ännu. + Poster i prislistan + Post i prislistan + Ny post + Denna prislista har inga poster ännu. + Spara prislistan innan du lägger till poster. + Avdelningens standard (används när kunden saknar vald prislista) + Ta bort denna prislista? + Ta bort denna post? + Prislistan togs bort. + Typ + Per timme – enhetens tid på plats + Per timme – personaltid + Fast avgift per larm + Fast avgift + Körsträcka + Material + Sats + Enhetsetikett + Minimiavgift + Minsta antal minuter + Avrunda uppåt till (minuter) + Avrundning + Min. + avrunda till + Enhetstyp + Alla enhetstyper + Skrivs på fakturaraden; standard är postens namn. + Sorteringsordning + Skattepliktig + Aktiv + Inaktiv + Standard + Namn + Beskrivning + Redigera + Öppna + Lägg till + Ta bort + Alla + Filtrera + Laddar… + Status + Utkast + Skickad + Delvis betald + Betald + Förfallen + Makulerad + Nummer + Kund + Utfärdad + Förfaller + Lämna tomt för profilens villkor: nettodagar + Skickad + Makulerad + Betald den + Totalt + Summor + Delsumma + Rabatt + Rabatt % + Skatt + Betalt belopp + Saldo + Utestående saldo + Förfallet saldo + Ny faktura + Välj kund; utkastet hämtar villkor, rabatt och prislista från faktureringsprofilen. + Välj kund… + Kunder med faktureringsprofil + Övriga kontakter + Kontakten saknar faktureringsprofil. + Skapa faktureringsprofil + Välj en kund med aktiv faktureringsprofil. + Valuta + Skapa utkast + Redigera utkastet, lägg till larm från en prislista, förhandsgranska och skicka. + Anteckningar + Villkor + Rader + Inga rader. + Antal + Sats + Belopp + Lägg till rad + Lägg till larm + Raderna skapas från prislistan och enheternas tid på plats; redigera dem innan du sparar. + Larm nr + Larm + Loggad + Inga larm är kopplade till denna kund. + Redan på fakturan + Larmrader tillagda. Spara fakturan för att behålla dem. + Kunde inte läsa in data. + Ändringen kunde inte sparas. + Sparat. + De angivna värdena är inte giltiga. + Ett namn krävs. + Detta kräver ett aktivt Business Operations-tillägg. + Rabatt och skatt räknas om när du sparar. + Förhandsgranska & skicka + Tillbaka till fakturor + Villkor (nettodagar) + Skattebefriad + Skattesats % + Faktureringsprofil + Vart fakturor skickas, betalningsvillkor, skattehantering och kundens prislista. + Ingen faktureringsprofil ännu. Spara en för att börja fakturera kontakten. + Öppna kontakt + Faktura-e-post + Fakturor skickas hit; standard är kontaktens e-post. + Standardrabatt % + Prislista + Använd avdelningens standard + Inköpsordernummer krävs på fakturor + Aktiv (nya fakturor kan skapas) + Faktureringsadress + Använd kontaktens postadress + Adress + Ort + Län + Postnummer + Land + Enhetlig sats för skattepliktiga rader när inga komponenter anges nedan. + Skattekomponenter + Upp till tre namngivna skatter (till exempel GST och PST), var och en med eget registreringsnummer. + Sats % + Registreringsnummer + Alla fakturor för kunden + Inga fakturor. + Betalningar + Inga betalningar registrerade. + Metod + Check + Kontant + Banköverföring / ACH + Kort (utanför Resgrid) + Annat + Online + Betalningsstatus + Genomförd + Återbetald + Delvis återbetald + Bestriden + Tvist förlorad + Referens + Förhandsgranskning + Åtgärder + Redigera utkast + Ladda ned PDF + Skicka faktura + Skicka fakturan igen + Skickar faktura-PDF:en till kunden via e-post och markerar utkastet som skickat. + Skicka till + Skicka + Markera som skickad (ingen e-post) + Markera fakturan som skickad utan att e-posta den? + Registrera betalning + Endast manuella betalningar; onlinebetalningar registreras automatiskt. + Betalning registrerad. + Makulera faktura + En makulerad faktura behåller sitt nummer och kan inte redigeras eller betalas. + Orsak + Fakturan skickad. + Fakturan markerad som skickad. + Fakturan makulerad. + PDF:en kunde inte skapas just nu. + Åldersanalys + Åldersanalys av kundfordringar + Öppna saldon grupperade efter hur länge de varit förfallna. + Per + Ej förfallet + dagar förfallet + Inga utestående fakturor. + Faktureringsinställningar + Vad fakturorna säger om er avdelning, samt alternativ för onlinebetalning. + Faktureringsidentitet + Skrivs ut på varje faktura och PDF. + Juridiskt namn + Betalningsadress + Registreringar + Skatteregistreringsnummer + Sekundär skatteregistrering + SAM UEI + CAGE-kod + Konto för arbetsskadeförsäkring + Fakturasidfot + Betalningsinstruktioner, tack eller juridisk text under summorna. + Betallänkens giltighet (dagar) + Visa länken ”Betala online” på fakturor + Onlinebetalningar + Onlinebetalning (Stripe) är inte tillgänglig ännu. Fakturor kan betalas med check, kontant, banköverföring eller kort utanför Resgrid och registreras manuellt. + Tillgänglighet i regionen + Avdelningsflagga + När det blir tillgängligt kopplar avdelningen sitt eget Stripe-konto; Resgrid håller aldrig pengarna. + Tillgänglig + Inte tillgänglig i denna region + Aktiverad + Inaktiverad + Tillägget Business Operations + Ett månadstillägg för avdelningar som fakturerar kunder och hanterar avtal. Endast avdelningens ansvariga medlem kan köpa eller avsluta det. + Kundfakturering, prislistor, betalningar och åldersanalys + Entreprenörstaxor, avtal och kostnadsåtervinning (kommer senare) + Lönedata och fältkostnader (kommer senare) + Tillägget är aktivt för er avdelning. + månad + Er avdelning har inte detta tillägg ännu. + Betald till + Förnyelsen avbruten; åtkomsten fortsätter till betalperiodens slut. + Köp + Avbryt förnyelse + Avbryta förnyelsen av Business Operations? + Fakturering är inte tillgänglig just nu. Försök igen senare. + Hantera prenumeration + Modulinställningar + Länkar + Larmet hittades inte. + Kontakten hittades inte. + Lägg till minst en rad innan du skickar. + En faktura med betalningar kan inte makuleras. + Endast ett fakturautkast kan redigeras. + Fakturan hittades inte. + Fakturan kan inte ta emot betalning i sin nuvarande status. + En betald faktura kan inte makuleras. + Fakturan är makulerad. + Ingen faktura-e-post är angiven för kunden. + Betalningen hittades inte. + Faktura-PDF:en kunde inte skapas. + Faktureringsprofilen har fakturor och kan inte tas bort. + Kunden behöver först en aktiv faktureringsprofil. + Prislistan hittades inte. + diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx new file mode 100644 index 000000000..64a290d33 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx @@ -0,0 +1,265 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Виставлення рахунків + Керування рахунками + Перегляд рахунків + Створення, редагування, надсилання та анулювання рахунків, реєстрація платежів, керування тарифами та профілями виставлення рахунків. + Перегляд рахунків, аналізу простроченої дебіторської заборгованості та PDF рахунків. + Рахунок створено + Рахунок надіслано + Платіж за рахунком зареєстровано + Рахунок оплачено + Рахунок прострочено + Рахунок анульовано + Платіж за рахунком повернуто + Платіж за рахунком оскаржено + Виставлення рахунків + Рахунки + рахунків + Рахунок + Рахунки клієнтам, платежі та дебіторська заборгованість. + Доповнення Business Operations не активне для цього підрозділу. Наявні рахунки залишаються доступними для читання; створення або зміни потребують активного доповнення. + Business Operations + Виставлення рахунків, тарифи та профілі оплати. Сторінки також потребують доповнення Business Operations. + Тарифи + Тариф + Тарифи, що застосовуються під час додавання викликів до рахунку. + Тариф містить ваші ставки: погодинний час підрозділів або персоналу, фіксовану плату за виклик, сталі збори, пробіг і матеріали. Позначте один тариф як типовий; профіль оплати клієнта може закріпити інший. + Новий тариф + Тарифів ще немає. + Позиції тарифу + Позиція тарифу + Нова позиція + Цей тариф ще не має позицій. + Збережіть тариф, перш ніж додавати позиції. + Типовий для підрозділу (коли клієнт не має закріпленого тарифу) + Видалити цей тариф? + Видалити цю позицію? + Тариф видалено. + Тип + Погодинно – час підрозділу на місці + Погодинно – час персоналу + Фіксована плата за виклик + Сталий збір + Пробіг + Матеріал + Ставка + Позначка одиниці + Мінімальна плата + Мінімум хвилин + Округлювати до (хвилин) + Округлення + Мін. + округлити до + Тип підрозділу + Будь-який тип підрозділу + Друкується в рядку рахунку; типово назва позиції. + Порядок сортування + Оподатковується + Активний + Неактивний + Типовий + Назва + Опис + Редагувати + Відкрити + Додати + Вилучити + Усі + Фільтр + Завантаження… + Стан + Чернетка + Надіслано + Частково оплачено + Оплачено + Прострочено + Анульовано + Номер + Клієнт + Виставлено + Термін оплати + Залиште порожнім для умов профілю: днів нетто + Надіслано + Анульовано + Дата оплати + Разом + Підсумки + Проміжний підсумок + Знижка + Знижка % + Податок + Сплачена сума + Залишок + Несплачений залишок + Прострочений залишок + Новий рахунок + Оберіть клієнта; чернетка бере умови, знижку й тариф із профілю оплати. + Оберіть клієнта… + Клієнти з профілем оплати + Інші контакти + Цей контакт ще не має профілю оплати. + Налаштувати профіль оплати + Оберіть клієнта з активним профілем оплати. + Валюта + Створити чернетку + Відредагуйте чернетку, додайте виклики з тарифу, потім перегляньте й надішліть. + Примітки + Умови + Позиції + Немає позицій. + К-сть + Ставка + Сума + Додати позицію + Додати виклик + Позиції формуються з тарифу та часу підрозділів на місці; відредагуйте їх перед збереженням. + № виклику + Виклик + Зареєстровано + Немає викликів, пов’язаних із цим клієнтом. + Уже в цьому рахунку + Позиції виклику додано. Збережіть рахунок, щоб зберегти їх. + Не вдалося завантажити дані. + Не вдалося зберегти зміну. + Збережено. + Надіслані значення недійсні. + Потрібна назва. + Потрібне активне доповнення Business Operations. + Знижка й податок перераховуються під час збереження. + Перегляд і надсилання + Назад до рахунків + Умови (днів нетто) + Звільнено від податку + Ставка податку % + Профіль оплати + Куди надходять рахунки, умови оплати, оподаткування та тариф для цього клієнта. + Профілю оплати ще немає. Збережіть його, щоб виставляти рахунки цьому контакту. + Відкрити контакт + Ел. пошта для рахунків + Сюди надсилаються рахунки; типово ел. пошта контакту. + Типова знижка % + Тариф + Використати типовий для підрозділу + На рахунках потрібен номер замовлення + Активний (можна створювати нові рахунки) + Адреса для рахунків + Використати поштову адресу контакту + Адреса + Місто + Область + Поштовий індекс + Країна + Єдина ставка для оподатковуваних позицій, якщо нижче не вказано складників. + Складники податку + До трьох іменованих податків (наприклад GST і PST), кожен із власним реєстраційним номером. + Ставка % + Реєстраційний номер + Усі рахунки цього клієнта + Немає рахунків. + Платежі + Платежів не зареєстровано. + Спосіб + Чек + Готівка + Банківський переказ / ACH + Картка (поза Resgrid) + Інше + Онлайн + Стан платежу + Успішно + Повернуто + Частково повернуто + Оскаржено + Спір програно + Посилання + Попередній перегляд + Дії + Редагувати чернетку + Завантажити PDF + Надіслати рахунок + Надіслати рахунок повторно + Надсилає PDF рахунку клієнту ел. поштою та позначає чернетку як надіслану. + Надіслати до + Надіслати + Позначити як надіслану (без ел. пошти) + Позначити цей рахунок як надісланий без надсилання ел. поштою? + Зареєструвати платіж + Лише ручні платежі; онлайн-платежі реєструються автоматично. + Платіж зареєстровано. + Анулювати рахунок + Анульований рахунок зберігає номер і не може бути змінений чи оплачений. + Причина + Рахунок надіслано. + Рахунок позначено як надісланий. + Рахунок анульовано. + Наразі не вдалося створити PDF. + Прострочення + Прострочення дебіторської заборгованості + Відкриті залишки, згруповані за терміном прострочення. + Станом на + Поточні + днів прострочення + Немає несплачених рахунків. + Налаштування виставлення рахунків + Що ваші рахунки повідомляють про підрозділ, та варіанти онлайн-оплати. + Реквізити для рахунків + Друкується на кожному рахунку та PDF. + Юридична назва + Адреса для сплати + Реєстрації + Податковий номер + Додатковий податковий номер + SAM UEI + Код CAGE + Рахунок страхування працівників + Нижній колонтитул рахунку + Інструкції з оплати, подяка або юридичний текст під підсумками. + Термін дії посилання на оплату (днів) + Показувати посилання «Сплатити онлайн» на рахунках + Онлайн-платежі + Онлайн-оплата (Stripe) ще недоступна. Рахунки можна оплачувати чеком, готівкою, переказом або карткою поза Resgrid і реєструвати вручну. + Доступність у регіоні + Прапорець підрозділу + Коли стане доступно, ваш підрозділ підключить власний рахунок Stripe; Resgrid ніколи не утримує кошти. + Доступно + Недоступно в цьому регіоні + Увімкнено + Вимкнено + Доповнення Business Operations + Щомісячне доповнення для підрозділів, які виставляють рахунки клієнтам і керують контрактами. Придбати або скасувати його може лише керівний член підрозділу. + Виставлення рахунків клієнтам, тарифи, платежі та прострочення заборгованості + Тарифні сітки підрядників, контракти та відшкодування витрат (згодом) + Дані про оплату праці та польові витрати (згодом) + Доповнення активне для вашого підрозділу. + місяць + Ваш підрозділ ще не має цього доповнення. + Оплачено до + Поновлення скасовано; доступ триває до кінця оплаченого періоду. + Придбати + Скасувати поновлення + Скасувати поновлення Business Operations? + Оплата наразі недоступна. Спробуйте пізніше. + Керувати підпискою + Налаштування модулів + Посилання + Виклик не знайдено. + Контакт не знайдено. + Додайте хоча б одну позицію перед надсиланням. + Рахунок із платежами не можна анулювати. + Редагувати можна лише чернетку рахунку. + Рахунок не знайдено. + Цей рахунок не може прийняти платіж у поточному стані. + Оплачений рахунок не можна анулювати. + Цей рахунок анульовано. + Для цього клієнта не вказано ел. пошту для рахунків. + Платіж не знайдено. + Не вдалося створити PDF рахунку. + Цей профіль оплати має рахунки й не може бути видалений. + Клієнту спочатку потрібен активний профіль оплати. + Тариф не знайдено. + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index d16c46b4a..441b9d8ab 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -221,6 +221,26 @@ public enum AuditLogTypes ChecklistOccurrenceSkipped, ChecklistReminderSettingsUpdated, WorkOrderChanged, - InventoryChanged + InventoryChanged, + + // Workforce & Business Operations plan Phase B (customer invoicing). Append-only. + BillingProfileChanged, + RateCardChanged, + InvoiceCreated, + InvoiceUpdated, + InvoiceSent, + InvoiceVoided, + InvoicePaymentRecorded, + DepartmentBillingIdentityChanged, + + // Phase B2 online payments. + PaymentConnectionConnected, + PaymentConnectionDisconnected, + PaymentConnectionRevoked, + PaymentConnectionActionRequired, + InvoicePaymentRequestCreated, + InvoicePaymentRefunded, + InvoicePaymentDisputed, + PaymentWebhookRejected } } diff --git a/Core/Resgrid.Model/DepartmentModuleSettings.cs b/Core/Resgrid.Model/DepartmentModuleSettings.cs index 6df8ae8e4..87970acfb 100644 --- a/Core/Resgrid.Model/DepartmentModuleSettings.cs +++ b/Core/Resgrid.Model/DepartmentModuleSettings.cs @@ -1,4 +1,4 @@ -using ProtoBuf; +using ProtoBuf; namespace Resgrid.Model { @@ -64,5 +64,9 @@ public class DepartmentModuleSettings [ProtoMember(23)] public bool ChecklistsDisabled { get; set; } + + /// Department switch for the Business Operations module (Workforce & Business Operations plan). Next free tag after this is 25. + [ProtoMember(24)] + public bool BusinessOperationsDisabled { get; set; } } } diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index acd2d689a..9df2cf4ae 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -87,5 +87,28 @@ public static class FeatureFlagKeys /// Unified Search: cross-entity search over the global Lucene index plus the system-functionality command palette. Requires SearchConfig.Enabled in every process. Seeded off by M0208 (registry §4F). public const string SearchUnified = "Search.Unified"; + + /// + /// Operator-only, per-cluster availability switch for department-connected Stripe payment collection on + /// invoices (Workforce & Business Operations plan, Phase B2). Prerequisite of Invoicing.OnlinePayments. + /// Only its global state counts — a department override never enables it — and ordinary department + /// administrators cannot see or change it (the Security.DepartmentProtectedDataEnrollment pattern). On in the + /// US cluster at launch, off in the EU cluster. Seeded off and permanent by M0212 (pending); until that + /// migration lands the flag row does not exist and the switch evaluates off everywhere. + /// + public const string PaymentsStripeConnect = "Payments.StripeConnect"; + + /// + /// Operator master toggle for the paid Business Operations add-on surfaces (Workforce & Business Operations + /// plan, decision 11): the FeatureFlagPrerequisite of every paid flag below. Seeded off by M0211. The customer's + /// purchase is the PlanAddonTypes.BusinessOperations entitlement; this flag is only the rollout/kill switch. + /// + public const string BusinessOperations = "Business.Operations"; + + /// Phase B customer invoicing (billing profiles, rate cards, invoices, payments, aging). Child of Business.Operations. Seeded off by M0211. + public const string CustomerInvoicing = "Invoicing.CustomerInvoicing"; + + /// Phase B2 online payment collection through a department's own Stripe account. Child of Invoicing.CustomerInvoicing and Payments.StripeConnect. Seeded off by M0212. + public const string OnlinePayments = "Invoicing.OnlinePayments"; } } diff --git a/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs b/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs new file mode 100644 index 000000000..1075432ea --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// + /// Makes a Contact billable (plan decision 4): a separate row referencing the Contact, so billing is optional per + /// contact and dispatch paths never depend on it. One live row per contact. + /// + public class CustomerBillingProfile : IEntity + { + [Required] + public string CustomerBillingProfileId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + public string ContactId { get; set; } + + /// ADP catalog 26 field (Phase B2) in an enrolled department. + public string BillingEmail { get; set; } + public int? BillingAddressId { get; set; } + public bool UseContactMailingAddress { get; set; } = true; + public int TermsNetDays { get; set; } = 30; + public bool TaxExempt { get; set; } + + /// Flat tax rate as a percentage (7.25 = 7.25%). Ignored when is present. + public decimal? TaxRate { get; set; } + + /// Named multi-component taxes (plan decision 23): JSON array of {name, ratePercent, registrationNumber}. + public string TaxComponentsJson { get; set; } + public string DefaultRateCardId { get; set; } + + /// Contact-level discount applied to every new invoice (plan decision 14), as a percentage. + public decimal? DefaultDiscountPercent { get; set; } + + /// Reserved for Phase C contractor billing. + public string DefaultRateScheduleId { get; set; } + public bool PurchaseOrderRequired { get; set; } + public string Notes { get; set; } + public bool Active { get; set; } = true; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + [NotMapped] + public string TableName => "CustomerBillingProfiles"; + + [NotMapped] + public string IdName => "CustomerBillingProfileId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CustomerBillingProfileId; } + set { CustomerBillingProfileId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One named tax component on a billing profile or, snapshotted with its amount, on an invoice (decision 23). + public class TaxComponent + { + public string Name { get; set; } + public decimal RatePercent { get; set; } + public string RegistrationNumber { get; set; } + + /// Set only on the invoice snapshot: the amount this component contributed. + public decimal? Amount { get; set; } + } +} diff --git a/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs b/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs new file mode 100644 index 000000000..decc9a719 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// + /// The department's own billing identity that prints on every invoice and bid (plan B1): legal name, remit-to + /// address, tax registrations and government registrations, plus the Phase B2 online-payment settings. One row + /// per department, keyed by DepartmentId; written through the repository's upsert. + /// + public class DepartmentBillingIdentity : IEntity + { + [Required] + public int DepartmentId { get; set; } + public string LegalBusinessName { get; set; } + public int? RemitToAddressId { get; set; } + + /// GST/HST number or EIN/TIN. Printed on invoices (CRA requirement for GST/HST). + public string TaxRegistrationNumber { get; set; } + + /// Provincial sales tax registration where applicable. + public string SecondaryTaxRegistrationNumber { get; set; } + public string SamUei { get; set; } + public string CageCode { get; set; } + public string WorkersCompAccountNumber { get; set; } + public string InvoiceFooterText { get; set; } + + // Phase B2 online payments + public bool OnlinePaymentsEnabled { get; set; } + public string DefaultPaymentConnectionId { get; set; } + + /// Comma-separated Stripe payment method types offered on Checkout ("card,us_bank_account"). + public string AllowedPaymentMethodsCsv { get; set; } + public int PayLinkExpiryDays { get; set; } = 30; + public bool ShowPayOnlineOnDocuments { get; set; } = true; + public DateTime UpdatedOn { get; set; } + public string UpdatedByUserId { get; set; } + + [NotMapped] + public string TableName => "DepartmentBillingIdentities"; + + [NotMapped] + public string IdName => "DepartmentId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return DepartmentId; } + set { DepartmentId = (int)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/Invoice.cs b/Core/Resgrid.Model/Invoicing/Invoice.cs new file mode 100644 index 000000000..88e71910f --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/Invoice.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// + /// A customer invoice (plan decisions 6–8, 12, 14, 23). Totals are recomputed server-side only; the number is + /// assigned from InvoiceNumberSequences at draft creation. Phase C contractor invoices and Phase B2 online + /// payments land on this same record. + /// + public class Invoice : IEntity + { + [Required] + public string InvoiceId { get; set; } + + [Required] + public int DepartmentId { get; set; } + public int InvoiceNumber { get; set; } + + [Required] + public string CustomerBillingProfileId { get; set; } + + [Required] + public string ContactId { get; set; } + + /// . + public int Status { get; set; } + public DateTime? IssuedOn { get; set; } + public DateTime? DueOn { get; set; } + public string Currency { get; set; } = "USD"; + public decimal SubTotal { get; set; } + public decimal? DiscountPercent { get; set; } + public decimal DiscountAmount { get; set; } + public decimal TaxAmount { get; set; } + public decimal Total { get; set; } + public decimal AmountPaid { get; set; } + + /// Snapshot of the applied tax components with per-component amounts (decision 23); null when a flat rate applied. + public string TaxComponentsJson { get; set; } + + /// ADP catalog 26 field (Phase B2) in an enrolled department. + public string Notes { get; set; } + public string TermsText { get; set; } + public DateTime? SentOn { get; set; } + + /// ADP catalog 26 field (Phase B2) in an enrolled department. + public string SentToEmail { get; set; } + public DateTime? PaidOn { get; set; } + public DateTime? VoidedOn { get; set; } + public string VoidReason { get; set; } + + /// Reserved (Phase B2): always null in v1, Resgrid takes no fee. + public decimal? PlatformFeeAmount { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + [NotMapped] + public List LineItems { get; set; } + + [NotMapped] + public List Payments { get; set; } + + [NotMapped] + public decimal Balance => Total - AmountPaid; + + [NotMapped] + public string TableName => "Invoices"; + + [NotMapped] + public string IdName => "InvoiceId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return InvoiceId; } + set { InvoiceId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "LineItems", "Payments", "Balance" }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs new file mode 100644 index 000000000..8b5a02e48 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// One invoice line. CallId links a line to the call it bills (plan decision 8): one invoice may consolidate many calls, and a call may yield several lines. + public class InvoiceLineItem : IEntity + { + [Required] + public string InvoiceLineItemId { get; set; } + + [Required] + public string InvoiceId { get; set; } + + [Required] + public int DepartmentId { get; set; } + public int? CallId { get; set; } + public string RateCardItemId { get; set; } + + [Required] + public string Description { get; set; } + public decimal Quantity { get; set; } = 1; + public decimal UnitRate { get; set; } + public decimal Amount { get; set; } + public bool Taxable { get; set; } = true; + public int SortOrder { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + [NotMapped] + public string TableName => "InvoiceLineItems"; + + [NotMapped] + public string IdName => "InvoiceLineItemId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return InvoiceLineItemId; } + set { InvoiceLineItemId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/InvoiceNumberSequence.cs b/Core/Resgrid.Model/Invoicing/InvoiceNumberSequence.cs new file mode 100644 index 000000000..79d85b80c --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/InvoiceNumberSequence.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// Per-department invoice numbering (plan decision 7). Only the repository's atomic increment writes it. + public class InvoiceNumberSequence : IEntity + { + [Required] + public int DepartmentId { get; set; } + public int NextInvoiceNumber { get; set; } = 1; + + [NotMapped] + public string TableName => "InvoiceNumberSequences"; + + [NotMapped] + public string IdName => "DepartmentId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return DepartmentId; } + set { DepartmentId = (int)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/InvoicePayment.cs b/Core/Resgrid.Model/Invoicing/InvoicePayment.cs new file mode 100644 index 000000000..34bc306d0 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/InvoicePayment.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// + /// A payment against an invoice. Manual records (plan decision 10) and Phase B2 online payments share this row; + /// online rows carry the provider id, fee/net, payer e-mail and a display-only method summary, never a PAN. + /// + public class InvoicePayment : IEntity + { + [Required] + public string InvoicePaymentId { get; set; } + + [Required] + public string InvoiceId { get; set; } + + [Required] + public int DepartmentId { get; set; } + public decimal Amount { get; set; } + + /// . + public int Method { get; set; } + + /// Check number, remittance reference. ADP catalog 26 field (Phase B2). + public string Reference { get; set; } + + /// Provider payment / charge / capture id (Phase B2); null for manual records. + public string GatewayTransactionId { get; set; } + public string PaymentRequestId { get; set; } + + /// ; null for manual records. + public int? Provider { get; set; } + + /// . + public int Status { get; set; } + public decimal RefundedAmount { get; set; } + public decimal? ProviderFeeAmount { get; set; } + public decimal? NetAmount { get; set; } + + /// ADP catalog 26 field (Phase B2). + public string PayerEmail { get; set; } + + /// "Visa •••• 4242", "ACH" — display only. + public string PaymentMethodSummary { get; set; } + + /// ADP catalog 26 field (Phase B2). + public string ReceiptUrl { get; set; } + + /// ADP catalog 26 field (Phase B2). + public string Notes { get; set; } + public DateTime PaidOn { get; set; } + + /// Null when a provider webhook recorded the payment. + public string RecordedByUserId { get; set; } + public DateTime AddedOn { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + /// The amount still counting toward the invoice after refunds and lost disputes. + [NotMapped] + public decimal EffectiveAmount => Status == (int)InvoicePaymentStatuses.DisputeLost ? 0 : Amount - RefundedAmount; + + [NotMapped] + public string TableName => "InvoicePayments"; + + [NotMapped] + public string IdName => "InvoicePaymentId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return InvoicePaymentId; } + set { InvoicePaymentId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "EffectiveAmount" }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs b/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs new file mode 100644 index 000000000..e56eb718f --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs @@ -0,0 +1,29 @@ +using System; + +namespace Resgrid.Model.Invoicing +{ + /// + /// Workflow routing for the Phase B invoice lifecycle triggers (plan B6, registry 52–57). The payload carries + /// identifiers, status, amounts and dates only: never a billing e-mail, note, void reason, payer e-mail or receipt + /// URL (the Phase B ADP catalog fields), and the contact name is REDACTED on a protected row. + /// + public static class InvoiceWorkflowPayload + { + /// (template variable, payload property) pairs; drives the variable catalog, context builder and sample data. + public static readonly (string Variable, string Property)[] Variables = + { + ("id", "InvoiceId"), ("number", "InvoiceNumber"), ("status", "Status"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), + ("currency", "Currency"), ("sub_total", "SubTotal"), ("discount_amount", "DiscountAmount"), ("tax_amount", "TaxAmount"), + ("total", "Total"), ("amount_paid", "AmountPaid"), ("balance", "Balance"), ("issued_on", "IssuedOn"), ("due_on", "DueOn"), + ("sent_on", "SentOn"), ("paid_on", "PaidOn"), ("payment_amount", "PaymentAmount"), ("payment_method", "PaymentMethod"), + ("payment_id", "PaymentId"), ("old_status", "OldStatus") + }; + + public static readonly int[] Triggers = { 52, 53, 54, 55, 56, 57, 94, 95 }; + + public static bool IsInvoice(int trigger) => Array.IndexOf(Triggers, trigger) >= 0; + + /// The outbox producer subsystem name for every invoicing event. + public const string Producer = "Invoicing"; + } +} diff --git a/Core/Resgrid.Model/Invoicing/InvoicingEnums.cs b/Core/Resgrid.Model/Invoicing/InvoicingEnums.cs new file mode 100644 index 000000000..a347aae0a --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/InvoicingEnums.cs @@ -0,0 +1,82 @@ +namespace Resgrid.Model.Invoicing +{ + /// Invoice lifecycle (plan decision 6). Transitions happen only inside InvoicingService; Overdue is set by the invoice maintenance worker. + public enum InvoiceStatus + { + Draft = 0, + Sent = 1, + PartiallyPaid = 2, + Paid = 3, + Overdue = 4, + Void = 5 + } + + /// What a rate card item charges for (plan decision 5). + public enum RateCardItemTypes + { + /// Per hour of unit time on the call (time on scene). + HourlyUnit = 0, + /// Per hour of personnel time on the call. + HourlyPersonnel = 1, + /// One flat charge per call. + FlatPerCall = 2, + /// A fixed fee added once per invoice or on demand. + FixedFee = 3, + /// Per mile or kilometre, quantity entered by the user. + Mileage = 4, + /// Materials or supplies, quantity entered by the user. + Material = 5 + } + + /// How an invoice payment was received. Online is written only by the Phase B2 provider path. + public enum InvoicePaymentMethods + { + Check = 0, + Cash = 1, + Ach = 2, + CardExternal = 3, + Other = 4, + Online = 5 + } + + /// State of a recorded payment after refunds or disputes (Phase B2). Manual payments stay Succeeded. + public enum InvoicePaymentStatuses + { + Succeeded = 0, + Refunded = 1, + PartiallyRefunded = 2, + Disputed = 3, + DisputeLost = 4 + } + + /// Online payment providers (Phase B2). Stripe only in v1; 2–4 are reserved for later adapters. + public enum PaymentProviders + { + Stripe = 1, + Square = 2, + PayPal = 3, + AuthorizeNet = 4 + } + + /// Lifecycle of a department's connection to its own provider account (Phase B2). + public enum PaymentConnectionStatuses + { + Pending = 0, + Connected = 1, + ActionRequired = 2, + Disconnected = 3, + Revoked = 4 + } + + /// Lifecycle of one hosted-checkout attempt against an invoice (Phase B2). + public enum PaymentRequestStatuses + { + Created = 0, + Opened = 1, + Processing = 2, + Completed = 3, + Expired = 4, + Cancelled = 5, + Failed = 6 + } +} diff --git a/Core/Resgrid.Model/Invoicing/InvoicingPermissionCatalog.cs b/Core/Resgrid.Model/Invoicing/InvoicingPermissionCatalog.cs new file mode 100644 index 000000000..1e5b4bd4d --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/InvoicingPermissionCatalog.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Department-configurable invoicing permissions (Workforce & Business Operations plan, B6; registry 40–41). + /// The single source of the no-row defaults: both fall back to department administrators. Joins the + /// ClaimsLogic and Security-page catalogs exactly as the Work Orders catalog does. + /// + public static class InvoicingPermissionCatalog + { + public static readonly IReadOnlyList All = new[] + { + new RecordPermissionDescriptor(PermissionTypes.ManageInvoicing, PermissionActions.DepartmentAdminsOnly, false, "ManageInvoicingNote", false), + new RecordPermissionDescriptor(PermissionTypes.ViewInvoicing, PermissionActions.DepartmentAdminsOnly, false, "ViewInvoicingNote", false) + }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/PaymentsWebhookHealth.cs b/Core/Resgrid.Model/Invoicing/PaymentsWebhookHealth.cs new file mode 100644 index 000000000..64b8d3500 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/PaymentsWebhookHealth.cs @@ -0,0 +1,76 @@ +using System; + +namespace Resgrid.Model.Invoicing +{ + /// + /// Value-free health of the Stripe Connect webhook path (Workforce & Business Operations plan, B2.5a). It is + /// returned on the anonymous v4 Health endpoint, so it carries no URL, secret, account, department or invoice + /// identifier: booleans, counts and timestamps only. A cluster where payment collection is switched off reports + /// false and true, because an intentionally disabled subsystem is not unhealthy. + /// + public class PaymentsWebhookHealth + { + /// PaymentConnectConfig.Enabled and the Payments.StripeConnect operator flag both hold in this process. + public bool Enabled { get; set; } + + /// The platform secret key, the Connect webhook secret and the public base URL are all present. + public bool WebhookConfigured { get; set; } + + /// + /// Stripe lists an enabled webhook endpoint at this cluster's webhook URL, in the configured live/test mode, whose + /// enabled events cover everything the receiver consumes. Null when the probe is disabled, skipped or Stripe could + /// not be reached. The API does not expose whether an endpoint is Connect-scoped; that is confirmed once at registration. + /// + public bool? EndpointRegistered { get; set; } + + /// UTC time the newest webhook event was received, if any. + public DateTime? LastEventReceivedOn { get; set; } + + /// UTC time the newest webhook event was applied to an invoice, if any. + public DateTime? LastEventAppliedOn { get; set; } + + /// There was payment activity in the last seven days but no event within PaymentConnectConfig.WebhookStaleAfterHours. + public bool Stale { get; set; } + + /// Events rejected in the last hour (bad signature, live-mode mismatch). + public int RejectedLastHour { get; set; } + + /// Events that failed to apply in the last hour. + public int FailedLastHour { get; set; } + + /// Open payment requests older than PaymentConnectConfig.RequestReconcileAfterMinutes that neither a webhook nor the worker has resolved. + public int OverdueOpenRequests { get; set; } + + /// UTC time of the invoice maintenance worker's last successful reconciliation pass, if any. + public DateTime? LastReconcileOn { get; set; } + + /// The one-line verdict uptime monitoring keys on. See . + public bool Healthy { get; set; } = true; + + /// The value a cluster reports when payment collection is off. + public static PaymentsWebhookHealth Disabled() + { + return new PaymentsWebhookHealth { Enabled = false, Healthy = true }; + } + + /// + /// Disabled is healthy. Enabled is healthy when the webhook is configured, Stripe did not say the endpoint is + /// missing (an unknown probe result does not fail the check), nothing is stale, nothing was rejected in the last + /// hour and no open request is overdue. + /// + public void ComputeHealthy() + { + if (!Enabled) + { + Healthy = true; + return; + } + + Healthy = WebhookConfigured + && EndpointRegistered != false + && !Stale + && RejectedLastHour == 0 + && OverdueOpenRequests == 0; + } + } +} diff --git a/Core/Resgrid.Model/Invoicing/RateCard.cs b/Core/Resgrid.Model/Invoicing/RateCard.cs new file mode 100644 index 000000000..a08e57d58 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/RateCard.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// Department-scoped per-call pricing (plan decision 5). A department has at most one default card; a billing profile may pin another. + public class RateCard : IEntity + { + [Required] + public string RateCardId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + public string Name { get; set; } + public string Description { get; set; } + public bool IsDefault { get; set; } + public bool Active { get; set; } = true; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + [NotMapped] + public List Items { get; set; } + + [NotMapped] + public string TableName => "RateCards"; + + [NotMapped] + public string IdName => "RateCardId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RateCardId; } + set { RateCardId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Items" }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/RateCardItem.cs b/Core/Resgrid.Model/Invoicing/RateCardItem.cs new file mode 100644 index 000000000..a6941fb7e --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/RateCardItem.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + /// One priced line on a rate card. Rates are decimal(18,4); rounding and minimums are applied by the draft-line generator (plan decision 9). + public class RateCardItem : IEntity + { + [Required] + public string RateCardItemId { get; set; } + + [Required] + public string RateCardId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + /// . + public int ItemType { get; set; } + + [Required] + public string Name { get; set; } + public string Description { get; set; } + public decimal Rate { get; set; } + + /// Printed after the quantity ("hour", "mile", "each"). + public string UnitLabel { get; set; } + + /// Hourly items: bill at least this many minutes. + public int? MinimumMinutes { get; set; } + + /// Hourly items: round elapsed time up to this increment. + public int? RoundingMinutes { get; set; } + public decimal? MinimumCharge { get; set; } + + /// Hourly-unit items: only units whose Unit.Type equals this type name generate a line. + public string UnitTypeFilter { get; set; } + + /// Hourly-personnel items: only personnel holding this role generate a line. + public int? PersonnelRoleIdFilter { get; set; } + public bool Taxable { get; set; } = true; + public int SortOrder { get; set; } + public bool Active { get; set; } = true; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + [NotMapped] + public string TableName => "RateCardItems"; + + [NotMapped] + public string IdName => "RateCardItemId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RateCardItemId; } + set { RateCardItemId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index da794ac18..2435729b3 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -160,7 +160,12 @@ public enum PermissionTypes /// Issue and return department equipment. Defaults to the inventory adjustment permission. IssueInventory = 48, /// Record controlled-substance inventory transactions. Defaults to department administrators. - ManageControlledSubstances = 49 + ManageControlledSubstances = 49, + + /// Workforce & Business Operations plan Phase B (registry 40): create/edit/send/void invoices, record payments, rate cards, billing profiles. Defaults to department administrators. + ManageInvoicing = 40, + /// Workforce & Business Operations plan Phase B (registry 41): view invoices, aging and PDFs. Defaults to department administrators. + ViewInvoicing = 41 } } diff --git a/Core/Resgrid.Model/PlanAddon.cs b/Core/Resgrid.Model/PlanAddon.cs index 37b5f64c7..3007c9364 100644 --- a/Core/Resgrid.Model/PlanAddon.cs +++ b/Core/Resgrid.Model/PlanAddon.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using ProtoBuf; using Resgrid.Framework; using System; @@ -42,9 +42,9 @@ public class PlanAddon : IEntity public string GetExternalKey() { - if (AddonType == (int)PlanAddonTypes.ReadinessPro) + if (AddonType == (int)PlanAddonTypes.ReadinessPro || AddonType == (int)PlanAddonTypes.BusinessOperations) { - // Readiness Pro has separate live/test prices; never use a live price in test mode. + // Readiness Pro and Business Operations have separate live/test prices; never use a live price in test mode. var priceId = Config.PaymentProviderConfig.IsTestMode ? TestExternalId : ExternalId; return string.IsNullOrWhiteSpace(priceId) ? null : priceId.Trim(); } @@ -80,9 +80,9 @@ public object IdValue public DateTime GetEndDateFromNow() { - // Readiness Pro has its own monthly interval, even on an annual base plan. + // Readiness Pro and Business Operations have their own monthly interval, even on an annual base plan. // Actual paid access uses the reconciled PaymentAddon interval, never this estimate. - if (AddonType == (int)PlanAddonTypes.ReadinessPro) + if (AddonType == (int)PlanAddonTypes.ReadinessPro || AddonType == (int)PlanAddonTypes.BusinessOperations) return DateTime.UtcNow.AddMonths(1); if (Plan != null) @@ -114,6 +114,8 @@ public string GetAddonName() return "Advanced Data Protection"; case PlanAddonTypes.ReadinessPro: return "Readiness Pro"; + case PlanAddonTypes.BusinessOperations: + return "Business Operations"; default: throw new ArgumentOutOfRangeException(); } diff --git a/Core/Resgrid.Model/PlanAddonTypes.cs b/Core/Resgrid.Model/PlanAddonTypes.cs index b2b35ecf4..91a0c1c21 100644 --- a/Core/Resgrid.Model/PlanAddonTypes.cs +++ b/Core/Resgrid.Model/PlanAddonTypes.cs @@ -4,6 +4,8 @@ public enum PlanAddonTypes { PTT = 1, ADP = 2, - ReadinessPro = 3 + ReadinessPro = 3, + /// Workforce & Business Operations (plan decision 42): monthly add-on gating customer invoicing, contractor billing, Cal OES MARS and workforce costing. Registry 2026-09-18. + BusinessOperations = 4 } } diff --git a/Core/Resgrid.Model/Providers/IEmailProvider.cs b/Core/Resgrid.Model/Providers/IEmailProvider.cs index 75985b955..b91228a75 100644 --- a/Core/Resgrid.Model/Providers/IEmailProvider.cs +++ b/Core/Resgrid.Model/Providers/IEmailProvider.cs @@ -44,6 +44,10 @@ Task SendUpgradePaymentReciept(string departmentName, string processDate, Task SendReportDeliveryMail(string email, string subject, string messageBody, string sentOn, string reportName, string attachmentFilename, byte[] attachmentData, string reportUrl, DepartmentEmailBranding branding); + /// Customer invoice with the PDF attached (Workforce & Business Operations plan, Phase B). payUrl is null until online payments are enabled (Phase B2). + Task SendInvoiceMail(string email, string subject, string messageBody, string sentOn, + string invoiceLabel, string attachmentFilename, byte[] attachmentData, string invoiceUrl, string payUrl, DepartmentEmailBranding branding); + Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content); diff --git a/Core/Resgrid.Model/Providers/IStripeConnectEndpointProbe.cs b/Core/Resgrid.Model/Providers/IStripeConnectEndpointProbe.cs new file mode 100644 index 000000000..5915e7315 --- /dev/null +++ b/Core/Resgrid.Model/Providers/IStripeConnectEndpointProbe.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Providers +{ + /// + /// Asks Stripe, with the platform key, whether a webhook endpoint exists for this cluster (plan B2.5a). Separated + /// from the service so the health logic is testable without the Stripe SDK. + /// + public interface IStripeConnectEndpointProbe + { + /// + /// True when an enabled endpoint at exists in the given live/test mode and its + /// enabled events include every entry of (or "*"); false when no such endpoint + /// is listed; null when the platform key is missing or Stripe could not be reached. + /// + Task IsEndpointRegisteredAsync(string expectedUrl, bool liveMode, IReadOnlyCollection requiredEvents); + } +} diff --git a/Core/Resgrid.Model/Repositories/IBusinessOperationsBillingRepository.cs b/Core/Resgrid.Model/Repositories/IBusinessOperationsBillingRepository.cs new file mode 100644 index 000000000..4d796f83b --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IBusinessOperationsBillingRepository.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Business Operations add-on billing account rows (M0211), mirror of IReadinessProBillingRepository. + public interface IBusinessOperationsBillingRepository + { + Task LockDepartmentAsync(int departmentId); + Task GetAsync(int departmentId); + Task FindAsync(string provider, string customerId, string subscriptionId, string checkoutId); + Task SaveAsync(BusinessOperationsBillingAccount account); + Task> PaymentsAsync(int departmentId, string planAddonId); + Task SavePaymentAsync(PaymentAddon payment, bool insert); + } +} diff --git a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs new file mode 100644 index 000000000..890ab5534 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Repositories +{ + /// Customer billing profiles (plan B3). + public interface ICustomerBillingProfileRepository : IRepository + { + Task GetByIdForDepartmentAsync(string customerBillingProfileId, int departmentId); + Task GetByContactIdAsync(string contactId, int departmentId); + Task> GetByContactIdsAsync(int departmentId, IEnumerable contactIds); + Task> GetAllForDepartmentAsync(int departmentId); + } + + /// Rate cards (plan B3). + public interface IRateCardRepository : IRepository + { + Task GetByIdForDepartmentAsync(string rateCardId, int departmentId); + Task> GetAllForDepartmentAsync(int departmentId); + Task GetDefaultForDepartmentAsync(int departmentId); + /// Clears IsDefault on every other live card of the department. + Task ClearDefaultAsync(int departmentId, string exceptRateCardId, CancellationToken cancellationToken = default); + } + + /// Rate card items (plan B3). + public interface IRateCardItemRepository : IRepository + { + Task GetByIdForDepartmentAsync(string rateCardItemId, int departmentId); + Task> GetByRateCardIdAsync(string rateCardId, int departmentId, bool includeInactive = false); + } + + /// A row of the accounts-receivable aging query: one open invoice with its balance and days past due. + public class InvoiceAgingRow + { + public string InvoiceId { get; set; } + public int InvoiceNumber { get; set; } + public string ContactId { get; set; } + public int Status { get; set; } + public DateTime? DueOn { get; set; } + public decimal Total { get; set; } + public decimal AmountPaid { get; set; } + public decimal Balance => Total - AmountPaid; + } + + /// Filter for the department invoice list. + public class InvoiceListFilter + { + public IEnumerable Statuses { get; set; } + public string ContactId { get; set; } + public DateTime? IssuedFromUtc { get; set; } + public DateTime? IssuedToUtc { get; set; } + public int Skip { get; set; } + public int Take { get; set; } = 50; + } + + /// Invoices (plan B3). + public interface IInvoiceRepository : IRepository + { + Task GetByIdForDepartmentAsync(string invoiceId, int departmentId); + Task GetByNumberAsync(int departmentId, int invoiceNumber); + Task> GetForDepartmentAsync(int departmentId, InvoiceListFilter filter); + Task CountForDepartmentAsync(int departmentId, InvoiceListFilter filter); + Task> GetByContactIdAsync(string contactId, int departmentId); + Task> GetInvoicesByStatusAsync(int departmentId, int status); + /// Sent or PartiallyPaid invoices, in any department, whose DueOn is before (the worker's overdue sweep). + Task> GetOverdueCandidatesAsync(DateTime asOfUtc, int take); + /// Open (Sent / PartiallyPaid / Overdue) invoices of a department with their balances. + Task> GetAgingDataAsync(int departmentId); + /// True when the contact has any invoice that is not Void and not deleted (contact delete guard, plan risk 6). + Task HasNonVoidInvoicesForContactAsync(string contactId, int departmentId); + } + + /// Invoice line items (plan B3). Lines have no soft delete: a draft's lines are replaced. + public interface IInvoiceLineItemRepository : IRepository + { + Task> GetByInvoiceIdAsync(string invoiceId, int departmentId); + Task> GetByCallIdAsync(int callId, int departmentId); + Task DeleteByInvoiceIdAsync(string invoiceId, int departmentId, CancellationToken cancellationToken = default); + Task DeleteByIdAsync(string invoiceLineItemId, int departmentId, CancellationToken cancellationToken = default); + } + + /// Invoice payments (plan B3). + public interface IInvoicePaymentRepository : IRepository + { + Task GetByIdForDepartmentAsync(string invoicePaymentId, int departmentId); + Task> GetByInvoiceIdAsync(string invoiceId, int departmentId); + Task GetByGatewayTransactionIdAsync(int provider, string gatewayTransactionId); + } + + /// Per-department invoice numbering (plan decision 7). + public interface IInvoiceNumberSequenceRepository : IRepository + { + /// Atomically returns the next number for the department and advances the sequence (dialect-specific SQL). + Task GetNextNumberAsync(int departmentId, CancellationToken cancellationToken = default); + } + + /// The department's billing identity (plan B1). + public interface IDepartmentBillingIdentityRepository : IRepository + { + Task GetByDepartmentIdAsync(int departmentId); + /// Insert-or-update keyed by DepartmentId. + Task UpsertAsync(DepartmentBillingIdentity identity, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IMessageRepository.cs b/Core/Resgrid.Model/Repositories/IMessageRepository.cs index c5c0d43c9..53d54abb6 100644 --- a/Core/Resgrid.Model/Repositories/IMessageRepository.cs +++ b/Core/Resgrid.Model/Repositories/IMessageRepository.cs @@ -24,6 +24,14 @@ public interface IMessageRepository: IRepository /// Task<IEnumerable<Message>>. Task> GetSentMessagesByUserIdAsync(string userId); + /// + /// Every non-deleted message owned by a department (M0137 column), recipients attached. Used by the + /// search projection rebuild; messages that predate the column and were never backfilled are not returned. + /// + /// The department identifier. + /// Task<IEnumerable<Message>>. + Task> GetMessagesByDepartmentIdAsync(int departmentId); + /// /// Gets the unread message count asynchronous. /// diff --git a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs index 1de796a28..83ea8ae7e 100644 --- a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs +++ b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs @@ -114,6 +114,7 @@ public static class SystemActionModules public const string Training = "Training"; public const string Inventory = "Inventory"; public const string Maintenance = "Maintenance"; + public const string BusinessOperations = "BusinessOperations"; } /// diff --git a/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs b/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs new file mode 100644 index 000000000..c3b2752db --- /dev/null +++ b/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Entitlement checks for the paid Business Operations add-on surfaces (Workforce & Business Operations plan, + /// decision 42). Each check is master flag → capability flag → module switch → billing configured → live + /// PaymentAddons window, exactly as ReadinessAccessService.CanUseMaintenanceAsync; no entitlement cache. + /// Free surfaces (pre-plans, certifications, the deployment core) never call this service. + /// + public interface IBusinessOperationsAccessService + { + /// The department may create and work invoices, rate cards and billing profiles (Phase B). + Task CanUseInvoicingAsync(int departmentId); + + /// The department may use rate schedules, contracts, bids and contractor invoice generation (Phase C). + Task CanUseContractorBillingAsync(int departmentId); + + /// The department may use the Cal OES MARS cost-recovery workspace (Phase C). + Task CanUseCostRecoveryAsync(int departmentId); + + /// The department may use workforce pay data, field costing and California pay data reporting (Phase E; also requires ADP). + Task CanUseWorkforceAsync(int departmentId); + + /// The department holds an active Business Operations add-on window right now (no flag or module checks). + Task HasActiveAddonAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IBusinessOperationsBillingService.cs b/Core/Resgrid.Model/Services/IBusinessOperationsBillingService.cs new file mode 100644 index 000000000..aaaa41c96 --- /dev/null +++ b/Core/Resgrid.Model/Services/IBusinessOperationsBillingService.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Core proxy for the Billing API's BusinessOperationsBilling controller (Workforce & Business Operations plan, + /// decision 42; cloned from Readiness Pro). Checkout and reconciliation live in the Billing API; Core only relays. + /// + public interface IBusinessOperationsBillingService + { + Task GetAsync(int departmentId); + Task BeginCheckoutAsync(int departmentId); + Task CancelRenewalAsync(int departmentId); + } +} + +namespace Resgrid.Model +{ + public sealed class BusinessOperationsBillingStatus + { + public string Provider { get; set; } + public string Currency { get; set; } + public decimal MonthlyAmount { get; set; } + public bool CheckoutAvailable { get; set; } + public bool Active { get; set; } + public bool CanCancel { get; set; } + public bool Cancelled { get; set; } + public DateTime? PaidThroughUtc { get; set; } + } + + public sealed class BusinessOperationsCheckout + { + public string Provider { get; set; } + public string Url { get; set; } + public string TransactionId { get; set; } + } + + /// Provider routing and an expiring checkout reference only; never a card, grant or invoice payload (mirror of ReadinessProBillingAccount, M0211). + public sealed class BusinessOperationsBillingAccount + { + public int DepartmentId { get; set; } + public string Provider { get; set; } + public string CustomerId { get; set; } + public string PlanAddonId { get; set; } + public string PriceId { get; set; } + public string SubscriptionId { get; set; } + public string CheckoutId { get; set; } + public string CheckoutUrl { get; set; } + public DateTime? CheckoutExpiresOn { get; set; } + public string CheckoutAttempt { get; set; } + public DateTime UpdatedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Services/IEmailService.cs b/Core/Resgrid.Model/Services/IEmailService.cs index 651710135..50f249626 100644 --- a/Core/Resgrid.Model/Services/IEmailService.cs +++ b/Core/Resgrid.Model/Services/IEmailService.cs @@ -208,6 +208,9 @@ Task SendUserCancellationNotificationToTeamAsync(Department department, Pa Task SendReportDeliveryAsync(EmailNotification email, int departmentId, string reportUrl, string reportName); + /// Customer invoice with the PDF attached (Workforce & Business Operations plan, Phase B). Honors DoNotBroadcast and department e-mail branding. + Task SendInvoiceAsync(EmailNotification email, int departmentId, string invoiceUrl, string payUrl, string invoiceLabel); + /// /// Sends a contact-method verification code to the user's email address. /// diff --git a/Core/Resgrid.Model/Services/IInvoicePaymentsService.cs b/Core/Resgrid.Model/Services/IInvoicePaymentsService.cs new file mode 100644 index 000000000..21018dedb --- /dev/null +++ b/Core/Resgrid.Model/Services/IInvoicePaymentsService.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// Online payment collection on Resgrid invoices through a department's own Stripe account (Workforce & + /// Business Operations plan, Phase B2). Scaffolded 2026-09-18 with the health read only; the connect, pay-link, + /// webhook-apply and reconciliation members arrive with migration M0212 and the Phase B invoicing tables. + /// + public interface IInvoicePaymentsService + { + /// + /// Health of the Stripe Connect webhook path for the v4 Health endpoint (plan B2.5a). Never throws; every + /// field falls back to its safe value, and a cluster with payment collection off reports healthy. + /// + Task GetWebhookHealthAsync(); + } +} diff --git a/Core/Resgrid.Model/Services/IInvoicingService.cs b/Core/Resgrid.Model/Services/IInvoicingService.cs new file mode 100644 index 000000000..77e66d834 --- /dev/null +++ b/Core/Resgrid.Model/Services/IInvoicingService.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; + +namespace Resgrid.Model.Services +{ + /// One bucket of the accounts-receivable aging report (plan B4). + public class InvoiceAgingBucket + { + public string Label { get; set; } + public int Count { get; set; } + public decimal Balance { get; set; } + public List Invoices { get; set; } = new List(); + } + + /// Accounts-receivable aging: current / 1–30 / 31–60 / 61–90 / 90+ days past due. + public class InvoiceAgingReport + { + public DateTime AsOfUtc { get; set; } + public List Buckets { get; set; } = new List(); + public decimal TotalBalance { get; set; } + public int TotalCount { get; set; } + } + + /// The department's Phase B invoicing surface (Workforce & Business Operations plan, B4). Every mutation audits; lifecycle transitions publish their Workflow trigger through the domain outbox. + public interface IInvoicingService + { + // Billing profiles + Task GetBillingProfileByContactIdAsync(string contactId, int departmentId); + Task GetBillingProfileByIdAsync(string customerBillingProfileId, int departmentId); + Task> GetBillingProfilesForDepartmentAsync(int departmentId); + Task SaveBillingProfileAsync(CustomerBillingProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteBillingProfileAsync(string customerBillingProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// True when the contact has a billing profile with any non-void invoice (the contact delete guard, plan risk 6). + Task ContactHasOpenBillingAsync(string contactId, int departmentId); + + // Rate cards + Task> GetRateCardsForDepartmentAsync(int departmentId); + Task GetRateCardByIdAsync(string rateCardId, int departmentId, bool includeInactiveItems = false); + Task SaveRateCardAsync(RateCard rateCard, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteRateCardAsync(string rateCardId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveRateCardItemAsync(RateCardItem item, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteRateCardItemAsync(string rateCardItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// The contact's pinned card, else the department default, else null. + Task GetEffectiveRateCardForContactAsync(string contactId, int departmentId); + + // Invoices + Task> GetInvoicesForDepartmentAsync(int departmentId, InvoiceListFilter filter); + Task CountInvoicesForDepartmentAsync(int departmentId, InvoiceListFilter filter); + Task> GetInvoicesByContactIdAsync(string contactId, int departmentId); + /// The invoice with its line items and payments loaded. + Task GetInvoiceByIdAsync(string invoiceId, int departmentId); + /// Assigns the next number, applies the profile's terms and default discount, and audits (Draft). + Task CreateDraftInvoiceAsync(int departmentId, string contactId, string userId, string ipAddress, string userAgent, string currency = null, CancellationToken cancellationToken = default); + /// Draft-only edit of header fields (notes, terms, discount, due date, currency); totals are recomputed. + Task SaveInvoiceAsync(Invoice invoice, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Replaces a draft's line items with the supplied list and recomputes totals. + Task SaveInvoiceLineItemsAsync(string invoiceId, int departmentId, List lineItems, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Editable draft lines for a call from a rate card: time on scene from unit states (plan decision 9), flat and fixed items. Nothing is saved. + Task> GenerateLineItemsFromCallAsync(int callId, string rateCardId, int departmentId); + /// Appends the generated lines for a call to a draft invoice and recomputes totals. + Task AddCallToInvoiceAsync(string invoiceId, int callId, string rateCardId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// SubTotal → discount → tax (per component when TaxComponentsJson is present, else the flat rate) → Total (plan B4). + Task RecalculateTotalsAsync(string invoiceId, int departmentId, CancellationToken cancellationToken = default); + /// Draft → Sent: sets IssuedOn, DueOn from the profile's terms when unset, SentOn/SentToEmail. Delivery (e-mail + PDF) is a separate step. + Task MarkSentAsync(string invoiceId, int departmentId, string sentToEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task VoidInvoiceAsync(string invoiceId, int departmentId, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// The one choke point for every payment, manual or online: updates AmountPaid and the Partial/Paid transition (plan B4). + Task RecordPaymentAsync(InvoicePayment payment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Applies a refund (or lost dispute) to a recorded payment and re-derives the invoice's status and balance (Phase B2). + Task ApplyPaymentRefundAsync(string invoicePaymentId, int departmentId, decimal refundedAmount, bool disputeLost, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Sent / PartiallyPaid invoices past DueOn become Overdue (the invoice maintenance worker's pass). Returns the number transitioned. lets the worker skip departments whose entitlement lapsed. + Task MarkOverdueInvoicesAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default); + Task GetAccountsReceivableAgingAsync(int departmentId, DateTime? asOfUtc = null); + + // Rendering and delivery (plan B4) + /// A self-contained HTML rendering of the invoice: department billing identity, customer, lines, discount line, each named tax component with its registration number, totals, terms. Never includes internal cost. + Task RenderInvoiceHtmlAsync(string invoiceId, int departmentId); + /// The HTML rendering converted through IPdfProvider; null when the invoice is not found. + Task GetInvoicePdfAsync(string invoiceId, int departmentId); + /// E-mails the invoice PDF to the customer (to the address given, else the billing profile's e-mail). A Draft is marked Sent first; a Sent invoice is re-sent without a status change. Returns the invoice. + Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + // Department billing identity + Task GetDepartmentBillingIdentityAsync(int departmentId); + Task SaveDepartmentBillingIdentityAsync(DepartmentBillingIdentity identity, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMessageService.cs b/Core/Resgrid.Model/Services/IMessageService.cs index 40796eb29..ab5fdb172 100644 --- a/Core/Resgrid.Model/Services/IMessageService.cs +++ b/Core/Resgrid.Model/Services/IMessageService.cs @@ -42,6 +42,14 @@ public interface IMessageService /// Task<List<Message>>. Task> GetSentMessagesByUserIdAsync(string userId); + /// + /// Every non-deleted message a department owns, recipients attached, in one query. Backs the search + /// projection rebuild; not cached. + /// + /// The department identifier. + /// Task<List<Message>>. + Task> GetAllMessagesForDepartmentAsync(int departmentId); + /// /// Gets the unread messages count by user identifier asynchronous. /// diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 2b2f4558f..87649b60b 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -386,6 +386,21 @@ public static IReadOnlyList GetVariableCatalog(Workf switch (eventType) { + case WorkflowTriggerEventType.InvoiceCreated: + case WorkflowTriggerEventType.InvoiceSent: + case WorkflowTriggerEventType.InvoicePaymentRecorded: + case WorkflowTriggerEventType.InvoicePaid: + case WorkflowTriggerEventType.InvoiceOverdue: + case WorkflowTriggerEventType.InvoiceVoided: + case WorkflowTriggerEventType.InvoicePaymentRefunded: + case WorkflowTriggerEventType.InvoicePaymentDisputed: + foreach (var pair in Invoicing.InvoiceWorkflowPayload.Variables) + list.Add(new TemplateVariableDescriptor("invoice." + pair.Variable, "Invoice " + pair.Variable.Replace('_', ' ') + (pair.Variable == "contact_name" ? "; REDACTED on a protected row" : ""), + pair.Variable is "number" or "status" or "old_status" ? "int" + : pair.Variable is "sub_total" or "discount_amount" or "tax_amount" or "total" or "amount_paid" or "balance" or "payment_amount" ? "decimal" + : pair.Variable.EndsWith("_on", System.StringComparison.Ordinal) ? "datetime" : "string", false)); + list.Add(new TemplateVariableDescriptor("invoice.url", "Authenticated invoice link", "string", false)); + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 216fdcd34..5b8d003ce 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -64,6 +64,17 @@ public enum WorkflowTriggerEventType DispatchShortfallDetected = 50, StationCoverageGapDetected = 51, + // Workforce & Business Operations plan Phase B: customer invoice lifecycle (registry 52-57; plan decision 22). + InvoiceCreated = 52, + InvoiceSent = 53, + InvoicePaymentRecorded = 54, + InvoicePaid = 55, + InvoiceOverdue = 56, + InvoiceVoided = 57, + // Phase B2 online payments (registry 94-95, taken from the buffer on 2026-09-18). + InvoicePaymentRefunded = 94, + InvoicePaymentDisputed = 95, + // -- Records (RMS) block 100-115 -- Identifier Allocation Registry section 3.2. Values 52-99 are // reserved by other pending plans and must not be taken here. Workflow definitions persist the // integer, so these are append-only and never renumbered. 103 (RecordApproved) and 113-114 are diff --git a/Core/Resgrid.Search/LuceneGlobalSearchService.cs b/Core/Resgrid.Search/LuceneGlobalSearchService.cs index 5c1d1142e..5a5df184b 100644 --- a/Core/Resgrid.Search/LuceneGlobalSearchService.cs +++ b/Core/Resgrid.Search/LuceneGlobalSearchService.cs @@ -79,9 +79,12 @@ public Task SearchAsync(int departmentId, GlobalSearchQuery _host.MaybeRefresh(); var lucene = BuildQuery(departmentId, query); - var take = query.Take <= 0 ? 50 : Math.Min(query.Take, Math.Max(1, SearchConfig.MaxResults)); - var skip = Math.Max(0, query.Skip); - var window = Math.Min(skip + take, Math.Max(1, SearchConfig.MaxResults)); + // Skip is clamped to the candidate ceiling before the addition: an unbounded offset would overflow the window + // negative and IndexSearcher.Search rejects a non-positive hit count instead of returning an empty page. + var max = Math.Max(1, SearchConfig.MaxResults); + var take = query.Take <= 0 ? 50 : Math.Min(query.Take, max); + var skip = Math.Min(Math.Max(0, query.Skip), max); + var window = Math.Min(skip + take, max); var searcher = manager.Acquire(); try diff --git a/Core/Resgrid.Search/LuceneIndexHost.cs b/Core/Resgrid.Search/LuceneIndexHost.cs index 37488596c..4ed5ff5db 100644 --- a/Core/Resgrid.Search/LuceneIndexHost.cs +++ b/Core/Resgrid.Search/LuceneIndexHost.cs @@ -28,6 +28,7 @@ public class LuceneIndexHost : IDisposable { private const string ManifestFileName = "manifest.json"; private const string LockFileName = "write.lock"; + private static readonly TimeSpan OrphanedTempAge = TimeSpan.FromHours(1); private readonly object _sync = new object(); private readonly object _writerSyncGate = new object(); @@ -426,7 +427,10 @@ private async Task PullCoreAsync(CancellationToken cancellationToken) foreach (var existing in System.IO.Directory.EnumerateFiles(localPath)) { var name = Path.GetFileName(existing); - if (wanted.ContainsKey(name) || string.Equals(name, LockFileName, StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + if (wanted.ContainsKey(name) || string.Equals(name, LockFileName, StringComparison.OrdinalIgnoreCase)) + continue; + // A .tmp still being written belongs to a concurrent pull; one left behind by a crashed process is garbage. + if (name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) && DateTime.UtcNow - SafeLastWriteUtc(existing) < OrphanedTempAge) continue; try { File.Delete(existing); } catch (Exception ex) { Logging.LogException(ex, $"Search index '{IndexName}': stale local file {name} could not be removed yet."); } @@ -457,12 +461,21 @@ private async Task DownloadIfNeededAsync(string localPath, string name, long len if (File.Exists(target) && new FileInfo(target).Length == length && !name.StartsWith("segments_", StringComparison.Ordinal)) return; - var tmp = target + ".tmp"; - try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } - await _store.DownloadFileAsync(IndexName, name, tmp, cancellationToken); - if (File.Exists(target)) - File.Delete(target); - File.Move(tmp, target); + // PullAsync, the writer startup pull and the background pull share no gate, so two pulls can reach the same + // target: a per-download temp name keeps them from truncating each other's partial file, and the overwriting + // move publishes whichever finished last atomically. + var tmp = $"{target}.{Guid.NewGuid():N}.tmp"; + try + { + await _store.DownloadFileAsync(IndexName, name, tmp, cancellationToken); + File.Move(tmp, target, overwrite: true); + } + catch + { + try { if (File.Exists(tmp)) File.Delete(tmp); } + catch (Exception cleanup) { Logging.LogException(cleanup, $"Search index '{IndexName}': partial download {Path.GetFileName(tmp)} could not be removed."); } + throw; + } } private void WipeLocalFiles(bool keepOpenHandles = false) @@ -489,6 +502,11 @@ private static long SafeLength(string path) try { return new FileInfo(path).Length; } catch { return 0; } } + private static DateTime SafeLastWriteUtc(string path) + { + try { return File.GetLastWriteTimeUtc(path); } catch { return DateTime.UtcNow; } + } + private Directory OpenConfiguredDirectory() { var path = IndexPath; diff --git a/Core/Resgrid.Services/BusinessOperationsAccessService.cs b/Core/Resgrid.Services/BusinessOperationsAccessService.cs new file mode 100644 index 000000000..828d3b0b7 --- /dev/null +++ b/Core/Resgrid.Services/BusinessOperationsAccessService.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// Plan decision 42 entitlement gate, cloned from ReadinessAccessService. Fails closed on any error. + public class BusinessOperationsAccessService : IBusinessOperationsAccessService + { + private readonly IFeatureToggleService _flags; + private readonly IDepartmentSettingsService _settings; + private readonly ISubscriptionsService _subscriptions; + + public BusinessOperationsAccessService(IFeatureToggleService flags, IDepartmentSettingsService settings, ISubscriptionsService subscriptions) + { + _flags = flags; + _settings = settings; + _subscriptions = subscriptions; + } + + public Task CanUseInvoicingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CustomerInvoicing); + + // Phase C / Phase E flag keys are declared when those phases are authored; until then the capability is off. + public Task CanUseContractorBillingAsync(int departmentId) => CanUseAsync(departmentId, "Invoicing.ContractorBilling"); + public Task CanUseCostRecoveryAsync(int departmentId) => CanUseAsync(departmentId, "CostRecovery.CalOesMars"); + public Task CanUseWorkforceAsync(int departmentId) => CanUseAsync(departmentId, "Workforce.InternalCosting"); + + private async Task CanUseAsync(int departmentId, string capabilityFlag) + { + if (departmentId <= 0) + return false; + + try + { + // The capability flag carries a FeatureFlagPrerequisite on Business.Operations (plan decision 11), so a + // fresh evaluation of the child already covers the master; the explicit master check keeps the kill + // switch effective even where a prerequisite row was never seeded. + if ((await _flags.EvaluateFreshAsync(FeatureFlagKeys.BusinessOperations, departmentId))?.IsEnabled != true) + return false; + if ((await _flags.EvaluateFreshAsync(capabilityFlag, departmentId))?.IsEnabled != true) + return false; + + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId, bypassCache: true); + if (settings == null || settings.BusinessOperationsDisabled) + return false; + + return await HasActiveAddonAsync(departmentId); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return false; + } + } + + public async Task HasActiveAddonAsync(int departmentId) + { + if (departmentId <= 0) + return false; + + try + { + // Generic billing helpers synthesize free forever PTT payments when billing is unconfigured. + // A paid add-on must not grant access through that fallback (Readiness Pro precedent). + if (string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) || + string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) + return false; + + var plans = await _subscriptions.GetAllAddonPlansByTypeAsync(PlanAddonTypes.BusinessOperations); + var ids = plans?.Where(x => x != null && x.AddonType == (int)PlanAddonTypes.BusinessOperations && + !string.IsNullOrWhiteSpace(x.PlanAddonId)).Select(x => x.PlanAddonId).Distinct().ToList(); + if (ids == null || ids.Count == 0) + return false; + + // No entitlement cache: cancellation and renewal take effect on the next write (plan decision 42). + var payments = await _subscriptions.GetCurrentPaymentAddonsForDepartmentAsync(departmentId, ids); + var now = DateTime.UtcNow; + return payments != null && payments.Any(x => x != null && x.DepartmentId == departmentId && + ids.Contains(x.PlanAddonId) && x.EffectiveOn != default && x.EffectiveOn <= now && x.EndingOn > now && + !string.Equals(x.TransactionId, "SYSTEM", StringComparison.OrdinalIgnoreCase) && + x.EndingOn != DateTime.MaxValue); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return false; + } + } + } +} diff --git a/Core/Resgrid.Services/BusinessOperationsBillingService.cs b/Core/Resgrid.Services/BusinessOperationsBillingService.cs new file mode 100644 index 000000000..8872248c9 --- /dev/null +++ b/Core/Resgrid.Services/BusinessOperationsBillingService.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using RestSharp; + +namespace Resgrid.Services +{ + /// + /// Proxy to the Billing API's BusinessOperationsBilling controller (int-CommonApis; plan decision 42), cloned + /// from ReadinessProBillingService. Returns null / false when billing is not configured so callers fail closed. + /// + public sealed class BusinessOperationsBillingService : IBusinessOperationsBillingService + { + private readonly Func _client; + + public BusinessOperationsBillingService(Func client) + { + _client = client; + } + + private async Task CallAsync(string action, int departmentId, bool post) + { + if (string.IsNullOrWhiteSpace(SystemBehaviorConfig.BillingApiBaseUrl) || string.IsNullOrWhiteSpace(ApiConfig.BackendInternalApikey)) + return default; + + var request = new RestRequest("/api/BusinessOperationsBilling/" + action, post ? Method.Post : Method.Get); + request.AddHeader("X-API-Key", ApiConfig.BackendInternalApikey); + request.AddQueryParameter("departmentId", departmentId.ToString()); + + var response = await _client().ExecuteAsync(request); + if (!response.IsSuccessful) + Framework.Logging.LogError($"Business Operations billing '{action}' failed for department {departmentId}: {(int)response.StatusCode}."); + + return response.IsSuccessful ? response.Data : default; + } + + public Task GetAsync(int departmentId) => CallAsync("Status", departmentId, false); + public Task BeginCheckoutAsync(int departmentId) => CallAsync("Checkout", departmentId, true); + public Task CancelRenewalAsync(int departmentId) => CallAsync("CancelRenewal", departmentId, true); + } +} diff --git a/Core/Resgrid.Services/ContactsService.cs b/Core/Resgrid.Services/ContactsService.cs index bd824ce66..19343da16 100644 --- a/Core/Resgrid.Services/ContactsService.cs +++ b/Core/Resgrid.Services/ContactsService.cs @@ -34,14 +34,22 @@ public class ContactsService : IContactsService private readonly Lazy _searchProjections; + /// Optional (Lazy: Invoicing depends on Contacts) — the contact delete guard of the Workforce & Business Operations plan (risk 6). + private readonly Lazy _invoicing; + + /// Thrown by when the contact still has a billing profile with non-void invoices. + public const string HasOpenBillingReason = "contacts_has_open_billing"; + public ContactsService(IContactsRepository contactsRepository, IContactNotesRepository contactNotesRepository, IContactCategoryRepository contactCategoryRepository, IContactNoteTypesRepository contactNoteTypesRepository, IContactAssociationsRepository contactAssociationsRepository, IContactPreplanRepository contactPreplanRepository, IContactPreplanHazardRepository contactPreplanHazardRepository, IContactAttachmentRepository contactAttachmentRepository, ICallsRepository callsRepository, ICallContactsRepository callContactsRepository, - IEventAggregator eventAggregator, Lazy protectedWriteService, Lazy ownershipGate, Lazy searchProjections = null) + IEventAggregator eventAggregator, Lazy protectedWriteService, Lazy ownershipGate, Lazy searchProjections = null, + Lazy invoicing = null) { _ownershipGate = ownershipGate; + _invoicing = invoicing; _contactsRepository = contactsRepository; _contactCategoryRepository = contactCategoryRepository; _contactNotesRepository = contactNotesRepository; @@ -227,6 +235,19 @@ public async Task DoesContactNoteTypeAlreadyExistAsync(int departmentId, s var auditEvent = NewAuditEvent(departmentId, userId, AuditLogTypes.ContactRemoved, ipAddress, userAgent); var contact = await _contactsRepository.GetByIdAsync(contactId); + if (contact == null) + return false; + + // Workforce & Business Operations plan, risk 6: a contact with money hanging off it cannot be deleted. + // Resolved lazily so Contacts never hard-depends on Invoicing; a department without invoicing sees no change. + if (_invoicing != null && await _invoicing.Value.ContactHasOpenBillingAsync(contactId, departmentId)) + { + auditEvent.Successful = false; + auditEvent.Before = contact.CloneJsonToString(); + _eventAggregator.SendMessage(auditEvent); + throw new InvalidOperationException(HasOpenBillingReason); + } + auditEvent.Before = contact.CloneJsonToString(); contact.IsDeleted = true; diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index 5c811158a..c15835f70 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -724,7 +724,7 @@ async Task getDepartmentMember() public async Task SaveDepartmentMemberAsync(DepartmentMember departmentMember, CancellationToken cancellationToken = default(CancellationToken)) { var saved = await _departmentMembersRepository.SaveOrUpdateAsync(departmentMember, cancellationToken); - if (_searchProjections != null && saved != null && (saved.IsDeleted || !saved.IsActive)) await _searchProjections.Value.RemoveAsync(saved.DepartmentId, SearchEntityTypes.Personnel, saved.UserId, cancellationToken); + await ProjectMembershipAsync(saved, cancellationToken); InvalidateDepartmentMemberInCache(departmentMember.UserId, departmentMember.DepartmentId); InvalidateDepartmentUserInCache(departmentMember.UserId, departmentMember.User); @@ -734,6 +734,31 @@ async Task getDepartmentMember() return saved; } + /// + /// Keeps the personnel search projection in step with the membership row. IsActive only marks the user's + /// currently selected department (a multi-department user has exactly one), so it is projected as metadata + /// rather than treated as a removal; only a deleted, disabled or hidden membership leaves the index, matching + /// the personnel list and the rebuild sweep. A live membership is re-projected so un-hiding or re-enabling a + /// member does not wait for the next rebuild. + /// + private async Task ProjectMembershipAsync(DepartmentMember saved, CancellationToken cancellationToken) + { + if (_searchProjections == null || saved == null || saved.DepartmentId <= 0 || string.IsNullOrWhiteSpace(saved.UserId)) + return; + + if (saved.IsDeleted || saved.IsDisabled.GetValueOrDefault() || saved.IsHidden.GetValueOrDefault()) + { + await _searchProjections.Value.RemoveAsync(saved.DepartmentId, SearchEntityTypes.Personnel, saved.UserId, cancellationToken); + return; + } + + UserProfile profile = null; + try { profile = await _userProfileRepository.GetProfileByUserIdAsync(saved.UserId); } + catch (Exception ex) { Logging.LogException(ex, $"Search projection skipped for member {saved.UserId} in department {saved.DepartmentId}: profile could not be loaded."); } + if (profile != null) + await _searchProjections.Value.ProjectPersonnelAsync(saved.DepartmentId, profile, null, saved.IsActive, cancellationToken); + } + public async Task GetDepartmentEmailSettingsAsync(int departmentId) { var settings = await _departmentCallEmailsRepository.GetAllByDepartmentIdAsync(departmentId); diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index 5bb5e01c6..7ee201b78 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -450,6 +450,18 @@ public async Task SendInviteAsync(Invite invite, string senderName, string return true; } + public async Task SendInvoiceAsync(EmailNotification email, int departmentId, string invoiceUrl, string payUrl, string invoiceLabel) + { + if (email == null || string.IsNullOrWhiteSpace(email.To) || email.AttachmentData == null) + return false; + if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) + return false; + + var branding = await GetEmailBrandingAsync(departmentId); + return await _emailProvider.SendInvoiceMail(email.To, email.Subject, email.Body ?? string.Empty, DateTime.UtcNow.ToString("G") + " UTC", + invoiceLabel, email.AttachmentName, email.AttachmentData, invoiceUrl, payUrl, branding); + } + public async Task SendReportDeliveryAsync(EmailNotification email, int departmentId, string reportUrl, string reportName) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) diff --git a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs new file mode 100644 index 000000000..23d429cec --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Phase B2 online payments. Scaffolded 2026-09-18 with the webhook health read only (plan B2.4 "Health" and + /// B2.5a); connect, pay-link, webhook-apply and reconciliation land with M0212. + /// + public class InvoicePaymentsService : IInvoicePaymentsService + { + /// Connect events the webhook receiver consumes (plan B2.1). The endpoint probe requires all of them. + public static readonly IReadOnlyCollection StripeConnectRequiredEvents = new[] + { + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", + "checkout.session.expired", + "payment_intent.succeeded", + "charge.refunded", + "charge.dispute.created", + "charge.dispute.closed", + "account.application.deauthorized", + "account.updated" + }; + + private static readonly TimeSpan EndpointProbeCacheWindow = TimeSpan.FromMinutes(15); + private static readonly object EndpointProbeLock = new object(); + private static DateTime _endpointProbeCheckedOnUtc = DateTime.MinValue; + private static bool? _endpointProbeResult; + private static string _endpointProbeUrl; + + private readonly IFeatureToggleService _featureToggleService; + private readonly IStripeConnectEndpointProbe _endpointProbe; + + public InvoicePaymentsService(IFeatureToggleService featureToggleService, IStripeConnectEndpointProbe endpointProbe) + { + _featureToggleService = featureToggleService; + _endpointProbe = endpointProbe; + } + + public async Task GetWebhookHealthAsync() + { + var health = new PaymentsWebhookHealth(); + + try + { + health.Enabled = Config.PaymentConnectConfig.Enabled && await IsClusterSwitchOnAsync(); + if (!health.Enabled) + { + health.ComputeHealthy(); + return health; + } + + health.WebhookConfigured = !string.IsNullOrWhiteSpace(Config.PaymentConnectConfig.StripeSecretKey) + && !string.IsNullOrWhiteSpace(Config.PaymentConnectConfig.StripeConnectWebhookSecret) + && !string.IsNullOrWhiteSpace(Config.PaymentConnectConfig.GetWebhookUrl()); + + if (health.WebhookConfigured && Config.PaymentConnectConfig.WebhookEndpointProbeEnabled) + health.EndpointRegistered = await ProbeEndpointRegisteredCachedAsync(); + + // LastEventReceivedOn, LastEventAppliedOn, Stale, RejectedLastHour, FailedLastHour, OverdueOpenRequests and + // LastReconcileOn read PaymentProviderEvents and InvoicePaymentRequests, which arrive with M0212 (plan B2.2). + // Until then they hold their defaults: no activity, nothing stale, nothing rejected. + + health.ComputeHealthy(); + } + catch (Exception ex) + { + Logging.LogException(ex, "Payments webhook health could not be read."); + health.EndpointRegistered = null; + health.ComputeHealthy(); + } + + return health; + } + + /// + /// The Payments.StripeConnect operator flag is a per-cluster switch: only its global state counts, never a + /// department override, so it is read as a flag row rather than evaluated for a department. + /// + private async Task IsClusterSwitchOnAsync() + { + var flag = await _featureToggleService.GetFlagByKeyAsync(FeatureFlagKeys.PaymentsStripeConnect); + return flag != null && flag.IsEnabledGlobally && !flag.IsArchived; + } + + private async Task ProbeEndpointRegisteredCachedAsync() + { + var url = Config.PaymentConnectConfig.GetWebhookUrl(); + var now = DateTime.UtcNow; + + lock (EndpointProbeLock) + { + if (_endpointProbeUrl == url && now - _endpointProbeCheckedOnUtc < EndpointProbeCacheWindow) + return _endpointProbeResult; + } + + bool? result; + try + { + result = await _endpointProbe.IsEndpointRegisteredAsync(url, Config.PaymentConnectConfig.StripeLiveMode, StripeConnectRequiredEvents); + } + catch (Exception ex) + { + Logging.LogException(ex, "Stripe Connect webhook endpoint probe failed."); + result = null; + } + + lock (EndpointProbeLock) + { + _endpointProbeUrl = url; + _endpointProbeCheckedOnUtc = now; + _endpointProbeResult = result; + } + + return result; + } + + /// Clears the in-process endpoint probe cache (tests, and an operator action after re-registering the endpoint). + public static void ResetEndpointProbeCache() + { + lock (EndpointProbeLock) + { + _endpointProbeUrl = null; + _endpointProbeCheckedOnUtc = DateTime.MinValue; + _endpointProbeResult = null; + } + } + } +} diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs new file mode 100644 index 000000000..2ccc0fad9 --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Services.Invoicing +{ + /// Rendering and delivery (plan B4): HTML → PDF through IPdfProvider, e-mail with the PDF attached. Customer-facing output never contains internal cost. + public partial class InvoicingService + { + public async Task RenderInvoiceHtmlAsync(string invoiceId, int departmentId) + { + var invoice = await GetInvoiceByIdAsync(invoiceId, departmentId); + if (invoice == null) + return null; + + var identity = await GetDepartmentBillingIdentityAsync(departmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); + var contact = await _contactsService.GetContactByIdAsync(invoice.ContactId); + + var remitTo = identity?.RemitToAddressId.HasValue == true ? await SafeAddressAsync(identity.RemitToAddressId.Value) : null; + Address billTo = null; + if (profile != null && !profile.UseContactMailingAddress && profile.BillingAddressId.HasValue) + billTo = await SafeAddressAsync(profile.BillingAddressId.Value); + else if (contact?.MailingAddressId != null) + billTo = await SafeAddressAsync(contact.MailingAddressId.Value); + else if (contact?.PhysicalAddressId != null) + billTo = await SafeAddressAsync(contact.PhysicalAddressId.Value); + + var model = new InvoiceRenderModel + { + Invoice = invoice, + DepartmentName = string.IsNullOrWhiteSpace(identity?.LegalBusinessName) ? department?.Name : identity.LegalBusinessName, + RemitTo = remitTo, + TaxRegistrationNumber = identity?.TaxRegistrationNumber, + SecondaryTaxRegistrationNumber = identity?.SecondaryTaxRegistrationNumber, + FooterText = identity?.InvoiceFooterText, + CustomerName = invoice.IsProtected ? ProtectedDataEnvelope.RedactionValue : contact?.Name, + CustomerEmail = invoice.IsProtected ? null : (profile?.BillingEmail ?? contact?.Email), + BillTo = invoice.IsProtected ? null : billTo, + TaxComponents = ParseTaxComponents(invoice.TaxComponentsJson) + }; + + return RenderInvoiceHtml(model); + } + + public async Task GetInvoicePdfAsync(string invoiceId, int departmentId) + { + var html = await RenderInvoiceHtmlAsync(invoiceId, departmentId); + return html == null ? null : _pdfProvider.ConvertHtmlToPdf(html); + } + + public async Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + if (invoice.Status == (int)InvoiceStatus.Void) throw new InvalidOperationException("invoicing_invoice_void"); + + var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); + var recipient = string.IsNullOrWhiteSpace(toEmail) ? profile?.BillingEmail : toEmail.Trim(); + if (string.IsNullOrWhiteSpace(recipient)) throw new InvalidOperationException("invoicing_no_recipient_email"); + + // A draft becomes Sent first so the PDF carries the issue and due dates and the number is final. + if (invoice.Status == (int)InvoiceStatus.Draft) + invoice = await MarkSentAsync(invoiceId, departmentId, recipient, userId, ipAddress, userAgent, cancellationToken); + + var pdf = await GetInvoicePdfAsync(invoiceId, departmentId); + if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("invoicing_pdf_unavailable"); + + var label = $"Invoice #{invoice.InvoiceNumber}"; + var notification = new EmailNotification + { + To = recipient, + Subject = $"{label} from {await DepartmentDisplayNameAsync(departmentId)}", + Body = $"{label} for {FormatMoney(invoice.Total, invoice.Currency)} is attached." + (invoice.DueOn.HasValue ? $" Payment is due by {invoice.DueOn.Value:yyyy-MM-dd}." : string.Empty), + AttachmentName = $"invoice-{invoice.InvoiceNumber}.pdf", + AttachmentData = pdf + }; + + var sent = await _emailService.SendInvoiceAsync(notification, departmentId, InvoiceUrl(invoice.InvoiceId), null, label); + if (!sent) + Logging.LogError($"Invoice {invoice.InvoiceId} e-mail to the customer was not sent (department {departmentId})."); + + if (invoice.Status != (int)InvoiceStatus.Draft && (invoice.SentToEmail != recipient || invoice.SentOn == null)) + { + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceSent, ipAddress, userAgent); + audit.Before = Snapshot(invoice); + invoice.SentOn = DateTime.UtcNow; + invoice.SentToEmail = recipient; + invoice.EditedOn = invoice.SentOn; + invoice.EditedByUserId = userId; + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + audit.After = Snapshot(invoice); + _eventAggregator.SendMessage(audit); + } + + return await GetInvoiceByIdAsync(invoiceId, departmentId); + } + + private async Task
SafeAddressAsync(int addressId) + { + try { return await _addressService.GetAddressByIdAsync(addressId); } + catch (Exception ex) { Logging.LogException(ex, $"Invoice rendering: address {addressId} could not be read."); return null; } + } + + private async Task DepartmentDisplayNameAsync(int departmentId) + { + var identity = await _identities.GetByDepartmentIdAsync(departmentId); + if (!string.IsNullOrWhiteSpace(identity?.LegalBusinessName)) return identity.LegalBusinessName; + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + return department?.Name ?? "Resgrid"; + } + + private static string InvoiceUrl(string invoiceId) => + $"{(Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Invoicing/View/{invoiceId}"; + + /// Pure HTML rendering; unit-tested. HTML-encodes every user value. + public static string RenderInvoiceHtml(InvoiceRenderModel model) + { + var invoice = model.Invoice; + var currency = invoice.Currency ?? "USD"; + var sb = new StringBuilder(); + sb.Append("").Append(E($"Invoice #{invoice.InvoiceNumber}")).Append(""); + sb.Append(""); + + sb.Append("
"); + sb.Append("

").Append(E(model.DepartmentName ?? string.Empty)).Append("

"); + if (model.RemitTo != null) sb.Append("
").Append(E(FormatAddress(model.RemitTo))).Append("
"); + if (!string.IsNullOrWhiteSpace(model.TaxRegistrationNumber)) sb.Append("
Tax registration: ").Append(E(model.TaxRegistrationNumber)).Append("
"); + if (!string.IsNullOrWhiteSpace(model.SecondaryTaxRegistrationNumber)) sb.Append("
Secondary tax registration: ").Append(E(model.SecondaryTaxRegistrationNumber)).Append("
"); + sb.Append("
"); + sb.Append("

INVOICE

"); + sb.Append(""); + sb.Append(""); + if (invoice.IssuedOn.HasValue) sb.Append(""); + if (invoice.DueOn.HasValue) sb.Append(""); + sb.Append(""); + sb.Append("
Invoice #").Append(invoice.InvoiceNumber).Append("
Status").Append(E(StatusLabel(invoice.Status))).Append("
Issued").Append(invoice.IssuedOn.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append("
Due").Append(invoice.DueOn.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append("
Currency").Append(E(currency)).Append("
"); + + sb.Append("

Bill to

").Append(E(model.CustomerName ?? string.Empty)).Append("
"); + if (model.BillTo != null) sb.Append("
").Append(E(FormatAddress(model.BillTo))).Append("
"); + if (!string.IsNullOrWhiteSpace(model.CustomerEmail)) sb.Append("
").Append(E(model.CustomerEmail)).Append("
"); + + sb.Append("

Items

"); + foreach (var line in (invoice.LineItems ?? new List()).OrderBy(x => x.SortOrder)) + { + sb.Append(""); + } + sb.Append("
DescriptionQtyRateAmount
").Append(E(line.Description)).Append(line.Taxable ? string.Empty : " (non-taxable)") + .Append("").Append(line.Quantity.ToString("0.####", CultureInfo.InvariantCulture)) + .Append("").Append(FormatMoney(line.UnitRate, currency, 4)) + .Append("").Append(FormatMoney(line.Amount, currency)).Append("
"); + + sb.Append(""); + sb.Append(""); + if (invoice.DiscountAmount > 0) + sb.Append(""); + if (model.TaxComponents != null && model.TaxComponents.Count > 0) + { + foreach (var component in model.TaxComponents) + { + sb.Append(""); + } + } + else if (invoice.TaxAmount > 0) + sb.Append(""); + sb.Append(""); + if (invoice.AmountPaid > 0) + { + sb.Append(""); + sb.Append(""); + } + sb.Append("
Subtotal").Append(FormatMoney(invoice.SubTotal, currency)).Append("
Discount").Append(invoice.DiscountPercent.HasValue ? $" ({invoice.DiscountPercent.Value.ToString("0.##", CultureInfo.InvariantCulture)}%)" : string.Empty).Append("-").Append(FormatMoney(invoice.DiscountAmount, currency)).Append("
").Append(E(component.Name)).Append(" (").Append(component.RatePercent.ToString("0.##", CultureInfo.InvariantCulture)).Append("%)"); + if (!string.IsNullOrWhiteSpace(component.RegistrationNumber)) sb.Append(" ").Append(E(component.RegistrationNumber)).Append(""); + sb.Append("").Append(FormatMoney(component.Amount ?? 0m, currency)).Append("
Tax").Append(FormatMoney(invoice.TaxAmount, currency)).Append("
Total").Append(FormatMoney(invoice.Total, currency)).Append("
Paid-").Append(FormatMoney(invoice.AmountPaid, currency)).Append("
Balance due").Append(FormatMoney(invoice.Balance, currency)).Append("
"); + + if (!string.IsNullOrWhiteSpace(invoice.TermsText)) sb.Append("

Terms

").Append(E(invoice.TermsText)).Append("
"); + if (!string.IsNullOrWhiteSpace(invoice.Notes) && !invoice.IsProtected) sb.Append("

Notes

").Append(E(invoice.Notes)).Append("
"); + if (!string.IsNullOrWhiteSpace(model.PayUrl)) sb.Append("

Pay this invoice online

"); + if (!string.IsNullOrWhiteSpace(model.FooterText)) sb.Append("
").Append(E(model.FooterText)).Append("
"); + sb.Append(""); + return sb.ToString(); + } + + private static string StatusLabel(int status) => status switch + { + (int)InvoiceStatus.PartiallyPaid => "Partially paid", + _ => ((InvoiceStatus)status).ToString() + }; + + public static string FormatMoney(decimal amount, string currency, int decimals = 2) => + $"{amount.ToString("N" + decimals, CultureInfo.InvariantCulture)} {(currency ?? "USD").ToUpperInvariant()}"; + + private static string FormatAddress(Address address) + { + if (address == null) return string.Empty; + var parts = new[] { address.Address1, string.Join(" ", new[] { address.City, address.State, address.PostalCode }.Where(x => !string.IsNullOrWhiteSpace(x))), address.Country } + .Where(x => !string.IsNullOrWhiteSpace(x)); + return string.Join(", ", parts); + } + + private static string E(string value) => WebUtility.HtmlEncode(value ?? string.Empty); + } + + /// Everything the HTML renderer needs; assembled by RenderInvoiceHtmlAsync, hand-built in tests. + public class InvoiceRenderModel + { + public Invoice Invoice { get; set; } + public string DepartmentName { get; set; } + public Address RemitTo { get; set; } + public string TaxRegistrationNumber { get; set; } + public string SecondaryTaxRegistrationNumber { get; set; } + public string FooterText { get; set; } + public string CustomerName { get; set; } + public string CustomerEmail { get; set; } + public Address BillTo { get; set; } + public List TaxComponents { get; set; } + /// Phase B2: the pay-page link; null until online payments are enabled. + public string PayUrl { get; set; } + } +} diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.cs new file mode 100644 index 000000000..8ab907364 --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.cs @@ -0,0 +1,889 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Phase B customer invoicing (Workforce & Business Operations plan, B4). Money math is server-side only + /// (decision 12), numbers come from the atomic sequence (decision 7), status transitions happen only here + /// (decision 6), and every transition publishes its Workflow trigger through the domain outbox (decision 22). + /// Entitlement and feature-flag checks are the controllers' job; this service trusts its caller's authorization. + /// + public partial class InvoicingService : IInvoicingService + { + private readonly ICustomerBillingProfileRepository _profiles; + private readonly IRateCardRepository _rateCards; + private readonly IRateCardItemRepository _rateCardItems; + private readonly IInvoiceRepository _invoices; + private readonly IInvoiceLineItemRepository _lineItems; + private readonly IInvoicePaymentRepository _payments; + private readonly IInvoiceNumberSequenceRepository _sequence; + private readonly IDepartmentBillingIdentityRepository _identities; + private readonly IContactsService _contactsService; + private readonly ICallsService _callsService; + private readonly IUnitsService _unitsService; + private readonly IDomainEventOutboxService _outbox; + private readonly IEventAggregator _eventAggregator; + private readonly IPdfProvider _pdfProvider; + private readonly IEmailService _emailService; + private readonly IDepartmentsService _departmentsService; + private readonly IAddressService _addressService; + + public InvoicingService(ICustomerBillingProfileRepository profiles, IRateCardRepository rateCards, IRateCardItemRepository rateCardItems, + IInvoiceRepository invoices, IInvoiceLineItemRepository lineItems, IInvoicePaymentRepository payments, + IInvoiceNumberSequenceRepository sequence, IDepartmentBillingIdentityRepository identities, + IContactsService contactsService, ICallsService callsService, IUnitsService unitsService, + IDomainEventOutboxService outbox, IEventAggregator eventAggregator, + IPdfProvider pdfProvider, IEmailService emailService, IDepartmentsService departmentsService, IAddressService addressService) + { + _pdfProvider = pdfProvider; + _emailService = emailService; + _departmentsService = departmentsService; + _addressService = addressService; + _profiles = profiles; + _rateCards = rateCards; + _rateCardItems = rateCardItems; + _invoices = invoices; + _lineItems = lineItems; + _payments = payments; + _sequence = sequence; + _identities = identities; + _contactsService = contactsService; + _callsService = callsService; + _unitsService = unitsService; + _outbox = outbox; + _eventAggregator = eventAggregator; + } + + #region Billing profiles + + public Task GetBillingProfileByContactIdAsync(string contactId, int departmentId) => + _profiles.GetByContactIdAsync(contactId, departmentId); + + public Task GetBillingProfileByIdAsync(string customerBillingProfileId, int departmentId) => + _profiles.GetByIdForDepartmentAsync(customerBillingProfileId, departmentId); + + public async Task> GetBillingProfilesForDepartmentAsync(int departmentId) => + (await _profiles.GetAllForDepartmentAsync(departmentId))?.ToList() ?? new List(); + + public async Task SaveBillingProfileAsync(CustomerBillingProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrWhiteSpace(profile.ContactId)) throw new ArgumentException("ContactId is required.", nameof(profile)); + + var contact = await _contactsService.GetContactByIdAsync(profile.ContactId); + if (contact == null || contact.DepartmentId != profile.DepartmentId) + throw new InvalidOperationException("invoicing_contact_not_found"); + + ValidateTaxComponents(profile.TaxComponentsJson); + ValidatePercent(profile.DefaultDiscountPercent, nameof(profile.DefaultDiscountPercent)); + ValidatePercent(profile.TaxRate, nameof(profile.TaxRate)); + if (profile.TermsNetDays < 0 || profile.TermsNetDays > 365) throw new ArgumentException("TermsNetDays must be between 0 and 365.", nameof(profile)); + + if (!string.IsNullOrWhiteSpace(profile.DefaultRateCardId) && await _rateCards.GetByIdForDepartmentAsync(profile.DefaultRateCardId, profile.DepartmentId) == null) + throw new InvalidOperationException("invoicing_rate_card_not_found"); + + var existing = await _profiles.GetByContactIdAsync(profile.ContactId, profile.DepartmentId); + var now = DateTime.UtcNow; + var audit = NewAuditEvent(profile.DepartmentId, userId, AuditLogTypes.BillingProfileChanged, ipAddress, userAgent); + + if (existing != null) + { + audit.Before = Snapshot(existing); + // One live profile per contact: an incoming save always lands on the existing row. + profile.CustomerBillingProfileId = existing.CustomerBillingProfileId; + profile.AddedOn = existing.AddedOn; + profile.AddedByUserId = existing.AddedByUserId; + profile.IsProtected = existing.IsProtected; + profile.ProtectedCatalogVersion = existing.ProtectedCatalogVersion; + profile.EditedOn = now; + profile.EditedByUserId = userId; + } + else + { + profile.CustomerBillingProfileId = null; + profile.AddedOn = now; + profile.AddedByUserId = userId; + profile.EditedOn = null; + profile.EditedByUserId = null; + } + profile.IsDeleted = false; + + var saved = await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + return saved; + } + + public async Task DeleteBillingProfileAsync(string customerBillingProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByIdForDepartmentAsync(customerBillingProfileId, departmentId); + if (profile == null) return false; + if (await _invoices.HasNonVoidInvoicesForContactAsync(profile.ContactId, departmentId)) + throw new InvalidOperationException("invoicing_profile_has_invoices"); + + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.BillingProfileChanged, ipAddress, userAgent); + audit.Before = Snapshot(profile); + profile.IsDeleted = true; + profile.EditedOn = DateTime.UtcNow; + profile.EditedByUserId = userId; + await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + audit.After = Snapshot(profile); + _eventAggregator.SendMessage(audit); + return true; + } + + public async Task ContactHasOpenBillingAsync(string contactId, int departmentId) + { + if (string.IsNullOrWhiteSpace(contactId)) return false; + return await _invoices.HasNonVoidInvoicesForContactAsync(contactId, departmentId); + } + + #endregion + + #region Rate cards + + public async Task> GetRateCardsForDepartmentAsync(int departmentId) + { + var cards = (await _rateCards.GetAllForDepartmentAsync(departmentId))?.ToList() ?? new List(); + foreach (var card in cards) + card.Items = (await _rateCardItems.GetByRateCardIdAsync(card.RateCardId, departmentId))?.ToList() ?? new List(); + return cards; + } + + public async Task GetRateCardByIdAsync(string rateCardId, int departmentId, bool includeInactiveItems = false) + { + var card = await _rateCards.GetByIdForDepartmentAsync(rateCardId, departmentId); + if (card == null) return null; + card.Items = (await _rateCardItems.GetByRateCardIdAsync(rateCardId, departmentId, includeInactiveItems))?.ToList() ?? new List(); + return card; + } + + public async Task SaveRateCardAsync(RateCard rateCard, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (rateCard == null) throw new ArgumentNullException(nameof(rateCard)); + if (string.IsNullOrWhiteSpace(rateCard.Name)) throw new ArgumentException("Name is required.", nameof(rateCard)); + + var existing = string.IsNullOrWhiteSpace(rateCard.RateCardId) ? null : await _rateCards.GetByIdForDepartmentAsync(rateCard.RateCardId, rateCard.DepartmentId); + var now = DateTime.UtcNow; + var audit = NewAuditEvent(rateCard.DepartmentId, userId, AuditLogTypes.RateCardChanged, ipAddress, userAgent); + + if (existing != null) + { + audit.Before = Snapshot(existing); + rateCard.AddedOn = existing.AddedOn; + rateCard.AddedByUserId = existing.AddedByUserId; + rateCard.IsProtected = existing.IsProtected; + rateCard.ProtectedCatalogVersion = existing.ProtectedCatalogVersion; + rateCard.EditedOn = now; + rateCard.EditedByUserId = userId; + } + else + { + rateCard.RateCardId = null; + rateCard.AddedOn = now; + rateCard.AddedByUserId = userId; + } + rateCard.IsDeleted = false; + + var saved = await _rateCards.SaveOrUpdateAsync(rateCard, cancellationToken); + if (saved.IsDefault) + await _rateCards.ClearDefaultAsync(saved.DepartmentId, saved.RateCardId, cancellationToken); + + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + saved.Items = (await _rateCardItems.GetByRateCardIdAsync(saved.RateCardId, saved.DepartmentId, true))?.ToList() ?? new List(); + return saved; + } + + public async Task DeleteRateCardAsync(string rateCardId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var card = await _rateCards.GetByIdForDepartmentAsync(rateCardId, departmentId); + if (card == null) return false; + + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.RateCardChanged, ipAddress, userAgent); + audit.Before = Snapshot(card); + var now = DateTime.UtcNow; + foreach (var item in (await _rateCardItems.GetByRateCardIdAsync(rateCardId, departmentId, true)) ?? Enumerable.Empty()) + { + item.IsDeleted = true; + item.EditedOn = now; + item.EditedByUserId = userId; + await _rateCardItems.SaveOrUpdateAsync(item, cancellationToken); + } + card.IsDeleted = true; + card.IsDefault = false; + card.EditedOn = now; + card.EditedByUserId = userId; + await _rateCards.SaveOrUpdateAsync(card, cancellationToken); + audit.After = Snapshot(card); + _eventAggregator.SendMessage(audit); + return true; + } + + public async Task SaveRateCardItemAsync(RateCardItem item, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + if (string.IsNullOrWhiteSpace(item.Name)) throw new ArgumentException("Name is required.", nameof(item)); + if (item.Rate < 0) throw new ArgumentException("Rate cannot be negative.", nameof(item)); + if (!Enum.IsDefined(typeof(RateCardItemTypes), item.ItemType)) throw new ArgumentException("Unknown item type.", nameof(item)); + var card = await _rateCards.GetByIdForDepartmentAsync(item.RateCardId, item.DepartmentId); + if (card == null) throw new InvalidOperationException("invoicing_rate_card_not_found"); + + var existing = string.IsNullOrWhiteSpace(item.RateCardItemId) ? null : await _rateCardItems.GetByIdForDepartmentAsync(item.RateCardItemId, item.DepartmentId); + var now = DateTime.UtcNow; + var audit = NewAuditEvent(item.DepartmentId, userId, AuditLogTypes.RateCardChanged, ipAddress, userAgent); + if (existing != null) + { + audit.Before = Snapshot(existing); + item.AddedOn = existing.AddedOn; + item.AddedByUserId = existing.AddedByUserId; + item.IsProtected = existing.IsProtected; + item.ProtectedCatalogVersion = existing.ProtectedCatalogVersion; + item.EditedOn = now; + item.EditedByUserId = userId; + } + else + { + item.RateCardItemId = null; + item.AddedOn = now; + item.AddedByUserId = userId; + } + item.IsDeleted = false; + + var saved = await _rateCardItems.SaveOrUpdateAsync(item, cancellationToken); + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + return saved; + } + + public async Task DeleteRateCardItemAsync(string rateCardItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var item = await _rateCardItems.GetByIdForDepartmentAsync(rateCardItemId, departmentId); + if (item == null) return false; + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.RateCardChanged, ipAddress, userAgent); + audit.Before = Snapshot(item); + item.IsDeleted = true; + item.EditedOn = DateTime.UtcNow; + item.EditedByUserId = userId; + await _rateCardItems.SaveOrUpdateAsync(item, cancellationToken); + audit.After = Snapshot(item); + _eventAggregator.SendMessage(audit); + return true; + } + + public async Task GetEffectiveRateCardForContactAsync(string contactId, int departmentId) + { + var profile = string.IsNullOrWhiteSpace(contactId) ? null : await _profiles.GetByContactIdAsync(contactId, departmentId); + if (profile != null && !string.IsNullOrWhiteSpace(profile.DefaultRateCardId)) + { + var pinned = await GetRateCardByIdAsync(profile.DefaultRateCardId, departmentId); + if (pinned != null && pinned.Active) return pinned; + } + var fallback = await _rateCards.GetDefaultForDepartmentAsync(departmentId); + return fallback == null ? null : await GetRateCardByIdAsync(fallback.RateCardId, departmentId); + } + + #endregion + + #region Invoices + + public async Task> GetInvoicesForDepartmentAsync(int departmentId, InvoiceListFilter filter) => + (await _invoices.GetForDepartmentAsync(departmentId, filter ?? new InvoiceListFilter()))?.ToList() ?? new List(); + + public Task CountInvoicesForDepartmentAsync(int departmentId, InvoiceListFilter filter) => + _invoices.CountForDepartmentAsync(departmentId, filter ?? new InvoiceListFilter()); + + public async Task> GetInvoicesByContactIdAsync(string contactId, int departmentId) => + (await _invoices.GetByContactIdAsync(contactId, departmentId))?.ToList() ?? new List(); + + public async Task GetInvoiceByIdAsync(string invoiceId, int departmentId) + { + var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); + if (invoice == null) return null; + await LoadChildrenAsync(invoice); + return invoice; + } + + public async Task CreateDraftInvoiceAsync(int departmentId, string contactId, string userId, string ipAddress, string userAgent, string currency = null, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByContactIdAsync(contactId, departmentId); + if (profile == null || !profile.Active) throw new InvalidOperationException("invoicing_profile_required"); + + var now = DateTime.UtcNow; + var invoice = new Invoice + { + InvoiceId = null, + DepartmentId = departmentId, + InvoiceNumber = await _sequence.GetNextNumberAsync(departmentId, cancellationToken), + CustomerBillingProfileId = profile.CustomerBillingProfileId, + ContactId = profile.ContactId, + Status = (int)InvoiceStatus.Draft, + Currency = NormalizeCurrency(currency), + DiscountPercent = profile.DefaultDiscountPercent, + TaxComponentsJson = null, + AddedOn = now, + AddedByUserId = userId + }; + + var saved = await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceCreated, ipAddress, userAgent); + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + await PublishAsync(saved, WorkflowTriggerEventType.InvoiceCreated, cancellationToken: cancellationToken); + saved.LineItems = new List(); + saved.Payments = new List(); + return saved; + } + + public async Task SaveInvoiceAsync(Invoice invoice, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (invoice == null) throw new ArgumentNullException(nameof(invoice)); + var existing = await RequireDraftAsync(invoice.InvoiceId, invoice.DepartmentId); + ValidatePercent(invoice.DiscountPercent, nameof(invoice.DiscountPercent)); + + var audit = NewAuditEvent(existing.DepartmentId, userId, AuditLogTypes.InvoiceUpdated, ipAddress, userAgent); + audit.Before = Snapshot(existing); + + // Only the editable header fields move; numbers, status, money and provenance stay server-owned. + existing.Notes = invoice.Notes; + existing.TermsText = invoice.TermsText; + existing.DiscountPercent = invoice.DiscountPercent; + existing.DueOn = invoice.DueOn; + existing.IssuedOn = invoice.IssuedOn; + existing.Currency = NormalizeCurrency(invoice.Currency); + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + + await _invoices.SaveOrUpdateAsync(existing, cancellationToken); + var recalculated = await RecalculateTotalsAsync(existing.InvoiceId, existing.DepartmentId, cancellationToken); + audit.After = Snapshot(recalculated); + _eventAggregator.SendMessage(audit); + return recalculated; + } + + public async Task SaveInvoiceLineItemsAsync(string invoiceId, int departmentId, List lineItems, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await RequireDraftAsync(invoiceId, departmentId); + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceUpdated, ipAddress, userAgent); + audit.Before = Snapshot(invoice); + + await _lineItems.DeleteByInvoiceIdAsync(invoiceId, departmentId, cancellationToken); + var sort = 0; + foreach (var line in (lineItems ?? new List()).Where(x => x != null)) + { + if (string.IsNullOrWhiteSpace(line.Description)) throw new ArgumentException("Every line needs a description.", nameof(lineItems)); + line.InvoiceLineItemId = null; + line.InvoiceId = invoiceId; + line.DepartmentId = departmentId; + line.Amount = RoundMoney(line.Quantity * line.UnitRate); + line.SortOrder = sort++; + await _lineItems.SaveOrUpdateAsync(line, cancellationToken); + } + + var recalculated = await RecalculateTotalsAsync(invoiceId, departmentId, cancellationToken); + audit.After = Snapshot(recalculated); + _eventAggregator.SendMessage(audit); + return recalculated; + } + + public async Task> GenerateLineItemsFromCallAsync(int callId, string rateCardId, int departmentId) + { + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != departmentId) throw new InvalidOperationException("invoicing_call_not_found"); + var card = await GetRateCardByIdAsync(rateCardId, departmentId); + if (card == null) throw new InvalidOperationException("invoicing_rate_card_not_found"); + + var lines = new List(); + var callLabel = string.IsNullOrWhiteSpace(call.Number) ? call.CallId.ToString() : call.Number; + var states = (await _unitsService.GetUnitStatesForCallAsync(departmentId, callId))?.Where(x => x != null).OrderBy(x => x.Timestamp).ToList() ?? new List(); + var fallbackEnd = call.ClosedOn ?? DateTime.UtcNow; + + foreach (var item in card.Items.Where(x => x.Active && !x.IsDeleted).OrderBy(x => x.SortOrder)) + { + switch ((RateCardItemTypes)item.ItemType) + { + case RateCardItemTypes.HourlyUnit: + foreach (var group in states.GroupBy(x => x.UnitId)) + { + var unit = await _unitsService.GetUnitByIdAsync(group.Key); + if (unit == null) continue; + if (!string.IsNullOrWhiteSpace(item.UnitTypeFilter) && !string.Equals(unit.Type, item.UnitTypeFilter, StringComparison.OrdinalIgnoreCase)) continue; + var minutes = OnSceneMinutes(group.ToList(), call.LoggedOn, fallbackEnd); + if (minutes <= 0) continue; + var billable = ApplyRounding(minutes, item); + lines.Add(new InvoiceLineItem + { + CallId = callId, + RateCardItemId = item.RateCardItemId, + Description = $"{item.Name} — {unit.Name} — call {callLabel}", + Quantity = billable, + UnitRate = item.Rate, + Amount = Math.Max(RoundMoney(billable * item.Rate), item.MinimumCharge.HasValue ? RoundMoney(item.MinimumCharge.Value) : 0m), + Taxable = item.Taxable + }); + } + break; + case RateCardItemTypes.HourlyPersonnel: + // Personnel time on scene is not tracked per call today; emit an editable zero-quantity line so the + // clerk fills hours in (plan decision 9: the generator drafts, the user finishes). + lines.Add(new InvoiceLineItem { CallId = callId, RateCardItemId = item.RateCardItemId, Description = $"{item.Name} — call {callLabel}", Quantity = 0, UnitRate = item.Rate, Amount = 0, Taxable = item.Taxable }); + break; + case RateCardItemTypes.FlatPerCall: + lines.Add(new InvoiceLineItem { CallId = callId, RateCardItemId = item.RateCardItemId, Description = $"{item.Name} — call {callLabel}", Quantity = 1, UnitRate = item.Rate, Amount = RoundMoney(item.Rate), Taxable = item.Taxable }); + break; + case RateCardItemTypes.FixedFee: + case RateCardItemTypes.Mileage: + case RateCardItemTypes.Material: + // Quantity is the clerk's to enter; the line is offered with the rate prefilled. + lines.Add(new InvoiceLineItem { CallId = callId, RateCardItemId = item.RateCardItemId, Description = $"{item.Name} — call {callLabel}", Quantity = (RateCardItemTypes)item.ItemType == RateCardItemTypes.FixedFee ? 1 : 0, UnitRate = item.Rate, Amount = (RateCardItemTypes)item.ItemType == RateCardItemTypes.FixedFee ? RoundMoney(item.Rate) : 0, Taxable = item.Taxable }); + break; + } + } + + var sort = 0; + foreach (var line in lines) line.SortOrder = sort++; + return lines; + } + + public async Task AddCallToInvoiceAsync(string invoiceId, int callId, string rateCardId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await RequireDraftAsync(invoiceId, departmentId); + var generated = await GenerateLineItemsFromCallAsync(callId, rateCardId, departmentId); + var current = (await _lineItems.GetByInvoiceIdAsync(invoiceId, departmentId))?.ToList() ?? new List(); + current.AddRange(generated); + return await SaveInvoiceLineItemsAsync(invoice.InvoiceId, departmentId, current, userId, ipAddress, userAgent, cancellationToken); + } + + public async Task RecalculateTotalsAsync(string invoiceId, int departmentId, CancellationToken cancellationToken = default) + { + var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + var lines = (await _lineItems.GetByInvoiceIdAsync(invoiceId, departmentId))?.ToList() ?? new List(); + var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); + + ComputeTotals(invoice, lines, profile); + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + invoice.LineItems = lines; + invoice.Payments = (await _payments.GetByInvoiceIdAsync(invoiceId, departmentId))?.ToList() ?? new List(); + return invoice; + } + + /// SubTotal → discount → tax → Total, then AmountPaid from effective payments (plan B4; decisions 12, 14, 23). Pure and unit-tested. + public static void ComputeTotals(Invoice invoice, IReadOnlyCollection lines, CustomerBillingProfile profile, IReadOnlyCollection payments = null) + { + var subTotal = RoundMoney(lines.Sum(x => x.Amount)); + var taxableBase = RoundMoney(lines.Where(x => x.Taxable).Sum(x => x.Amount)); + var discount = invoice.DiscountPercent.HasValue && invoice.DiscountPercent.Value > 0 ? RoundMoney(subTotal * invoice.DiscountPercent.Value / 100m) : 0m; + if (discount > subTotal) discount = subTotal; + + // The discount is applied pre-tax and pro rata to the taxable base. + var taxableAfterDiscount = subTotal > 0 ? RoundMoney(taxableBase - discount * (taxableBase / subTotal)) : 0m; + var tax = 0m; + string componentsJson = null; + if (profile != null && !profile.TaxExempt) + { + var components = ParseTaxComponents(profile.TaxComponentsJson); + if (components.Count > 0) + { + foreach (var component in components) + { + component.Amount = RoundMoney(taxableAfterDiscount * component.RatePercent / 100m); + tax += component.Amount.Value; + } + componentsJson = JsonConvert.SerializeObject(components); + } + else if (profile.TaxRate.HasValue && profile.TaxRate.Value > 0) + { + tax = RoundMoney(taxableAfterDiscount * profile.TaxRate.Value / 100m); + } + } + + invoice.SubTotal = subTotal; + invoice.DiscountAmount = discount; + invoice.TaxAmount = RoundMoney(tax); + invoice.TaxComponentsJson = componentsJson; + invoice.Total = RoundMoney(subTotal - discount + tax); + if (payments != null) + invoice.AmountPaid = RoundMoney(payments.Sum(x => x.EffectiveAmount)); + } + + public async Task MarkSentAsync(string invoiceId, int departmentId, string sentToEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + if (invoice.Status != (int)InvoiceStatus.Draft) throw new InvalidOperationException("invoicing_invoice_not_draft"); + var lines = (await _lineItems.GetByInvoiceIdAsync(invoiceId, departmentId))?.ToList() ?? new List(); + if (lines.Count == 0) throw new InvalidOperationException("invoicing_invoice_has_no_lines"); + + var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceSent, ipAddress, userAgent); + audit.Before = Snapshot(invoice); + + var now = DateTime.UtcNow; + ComputeTotals(invoice, lines, profile); + invoice.IssuedOn ??= now; + invoice.DueOn ??= invoice.IssuedOn.Value.AddDays(profile?.TermsNetDays ?? 30); + invoice.SentOn = now; + invoice.SentToEmail = string.IsNullOrWhiteSpace(sentToEmail) ? profile?.BillingEmail : sentToEmail.Trim(); + invoice.Status = (int)InvoiceStatus.Sent; + invoice.EditedOn = now; + invoice.EditedByUserId = userId; + + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + audit.After = Snapshot(invoice); + _eventAggregator.SendMessage(audit); + await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceSent, oldStatus: (int)InvoiceStatus.Draft, cancellationToken: cancellationToken); + await LoadChildrenAsync(invoice); + return invoice; + } + + public async Task VoidInvoiceAsync(string invoiceId, int departmentId, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + if (invoice.Status == (int)InvoiceStatus.Void) return await GetInvoiceByIdAsync(invoiceId, departmentId); + if (invoice.Status == (int)InvoiceStatus.Paid) throw new InvalidOperationException("invoicing_invoice_paid_cannot_void"); + if (invoice.AmountPaid > 0) throw new InvalidOperationException("invoicing_invoice_has_payments_cannot_void"); + + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceVoided, ipAddress, userAgent); + audit.Before = Snapshot(invoice); + var oldStatus = invoice.Status; + var now = DateTime.UtcNow; + invoice.Status = (int)InvoiceStatus.Void; + invoice.VoidedOn = now; + invoice.VoidReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); + invoice.EditedOn = now; + invoice.EditedByUserId = userId; + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + audit.After = Snapshot(invoice); + _eventAggregator.SendMessage(audit); + await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceVoided, oldStatus: oldStatus, cancellationToken: cancellationToken); + await LoadChildrenAsync(invoice); + return invoice; + } + + public async Task RecordPaymentAsync(InvoicePayment payment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (payment == null) throw new ArgumentNullException(nameof(payment)); + if (payment.Amount <= 0) throw new ArgumentException("Amount must be positive.", nameof(payment)); + if (!Enum.IsDefined(typeof(InvoicePaymentMethods), payment.Method)) throw new ArgumentException("Unknown payment method.", nameof(payment)); + + var invoice = await _invoices.GetByIdForDepartmentAsync(payment.InvoiceId, payment.DepartmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + if (invoice.Status is (int)InvoiceStatus.Draft or (int)InvoiceStatus.Void) throw new InvalidOperationException("invoicing_invoice_not_payable"); + + // Online payments are idempotent on the provider's transaction id (Phase B2: the webhook may replay). + if (!string.IsNullOrWhiteSpace(payment.GatewayTransactionId) && payment.Provider.HasValue) + { + var duplicate = await _payments.GetByGatewayTransactionIdAsync(payment.Provider.Value, payment.GatewayTransactionId); + if (duplicate != null) return duplicate; + } + + var audit = NewAuditEvent(payment.DepartmentId, userId, AuditLogTypes.InvoicePaymentRecorded, ipAddress, userAgent); + audit.Before = Snapshot(invoice); + + var now = DateTime.UtcNow; + payment.InvoicePaymentId = null; + payment.Amount = RoundMoney(payment.Amount); + payment.Status = (int)InvoicePaymentStatuses.Succeeded; + payment.RefundedAmount = 0; + payment.RecordedByUserId = userId; + payment.AddedOn = now; + if (payment.PaidOn == default) payment.PaidOn = now; + var saved = await _payments.SaveOrUpdateAsync(payment, cancellationToken); + + var oldStatus = invoice.Status; + await ApplyPaymentStateAsync(invoice, userId, now, cancellationToken); + audit.After = Snapshot(invoice); + _eventAggregator.SendMessage(audit); + + await PublishAsync(invoice, WorkflowTriggerEventType.InvoicePaymentRecorded, saved, oldStatus, cancellationToken); + if (invoice.Status == (int)InvoiceStatus.Paid) + await PublishAsync(invoice, WorkflowTriggerEventType.InvoicePaid, saved, oldStatus, cancellationToken); + + return saved; + } + + public async Task ApplyPaymentRefundAsync(string invoicePaymentId, int departmentId, decimal refundedAmount, bool disputeLost, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var payment = await _payments.GetByIdForDepartmentAsync(invoicePaymentId, departmentId); + if (payment == null) throw new InvalidOperationException("invoicing_payment_not_found"); + if (refundedAmount < 0) throw new ArgumentException("Refunded amount cannot be negative.", nameof(refundedAmount)); + var invoice = await _invoices.GetByIdForDepartmentAsync(payment.InvoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + + var audit = NewAuditEvent(departmentId, userId, disputeLost ? AuditLogTypes.InvoicePaymentDisputed : AuditLogTypes.InvoicePaymentRefunded, ipAddress, userAgent); + audit.Before = Snapshot(invoice); + + payment.RefundedAmount = Math.Min(RoundMoney(refundedAmount), payment.Amount); + payment.Status = disputeLost ? (int)InvoicePaymentStatuses.DisputeLost + : payment.RefundedAmount >= payment.Amount ? (int)InvoicePaymentStatuses.Refunded + : payment.RefundedAmount > 0 ? (int)InvoicePaymentStatuses.PartiallyRefunded + : (int)InvoicePaymentStatuses.Succeeded; + await _payments.SaveOrUpdateAsync(payment, cancellationToken); + + var oldStatus = invoice.Status; + await ApplyPaymentStateAsync(invoice, userId, DateTime.UtcNow, cancellationToken); + audit.After = Snapshot(invoice); + _eventAggregator.SendMessage(audit); + await PublishAsync(invoice, disputeLost ? WorkflowTriggerEventType.InvoicePaymentDisputed : WorkflowTriggerEventType.InvoicePaymentRefunded, payment, oldStatus, cancellationToken); + return payment; + } + + public async Task MarkOverdueInvoicesAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default) + { + var candidates = (await _invoices.GetOverdueCandidatesAsync(asOfUtc, 1000))?.ToList() ?? new List(); + var count = 0; + var enabledByDepartment = new Dictionary(); + foreach (var invoice in candidates) + { + if (invoice.Status is not ((int)InvoiceStatus.Sent or (int)InvoiceStatus.PartiallyPaid)) continue; + if (departmentEnabled != null) + { + // The worker re-checks the Business Operations entitlement once per department per pass (plan decision 42). + if (!enabledByDepartment.TryGetValue(invoice.DepartmentId, out var enabled)) + enabledByDepartment[invoice.DepartmentId] = enabled = await departmentEnabled(invoice.DepartmentId); + if (!enabled) continue; + } + var audit = NewAuditEvent(invoice.DepartmentId, null, AuditLogTypes.InvoiceUpdated, null, null); + audit.Before = Snapshot(invoice); + var oldStatus = invoice.Status; + invoice.Status = (int)InvoiceStatus.Overdue; + invoice.EditedOn = asOfUtc; + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + audit.After = Snapshot(invoice); + _eventAggregator.SendMessage(audit); + await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceOverdue, oldStatus: oldStatus, cancellationToken: cancellationToken); + count++; + } + return count; + } + + public async Task GetAccountsReceivableAgingAsync(int departmentId, DateTime? asOfUtc = null) + { + var asOf = asOfUtc ?? DateTime.UtcNow; + var rows = (await _invoices.GetAgingDataAsync(departmentId))?.Where(x => x.Balance > 0).ToList() ?? new List(); + var report = new InvoiceAgingReport { AsOfUtc = asOf }; + var buckets = new[] + { + new InvoiceAgingBucket { Label = "Current" }, new InvoiceAgingBucket { Label = "1-30" }, new InvoiceAgingBucket { Label = "31-60" }, + new InvoiceAgingBucket { Label = "61-90" }, new InvoiceAgingBucket { Label = "90+" } + }; + foreach (var row in rows) + { + var daysPastDue = row.DueOn.HasValue ? (int)Math.Floor((asOf - row.DueOn.Value).TotalDays) : 0; + var bucket = daysPastDue <= 0 ? buckets[0] : daysPastDue <= 30 ? buckets[1] : daysPastDue <= 60 ? buckets[2] : daysPastDue <= 90 ? buckets[3] : buckets[4]; + bucket.Invoices.Add(row); + bucket.Count++; + bucket.Balance = RoundMoney(bucket.Balance + row.Balance); + } + report.Buckets.AddRange(buckets); + report.TotalCount = rows.Count; + report.TotalBalance = RoundMoney(rows.Sum(x => x.Balance)); + return report; + } + + #endregion + + #region Department billing identity + + public async Task GetDepartmentBillingIdentityAsync(int departmentId) => + await _identities.GetByDepartmentIdAsync(departmentId) ?? new DepartmentBillingIdentity { DepartmentId = departmentId, AllowedPaymentMethodsCsv = "card", PayLinkExpiryDays = 30, ShowPayOnlineOnDocuments = true }; + + public async Task SaveDepartmentBillingIdentityAsync(DepartmentBillingIdentity identity, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (identity == null) throw new ArgumentNullException(nameof(identity)); + if (identity.PayLinkExpiryDays < 1 || identity.PayLinkExpiryDays > 365) throw new ArgumentException("PayLinkExpiryDays must be between 1 and 365.", nameof(identity)); + var existing = await _identities.GetByDepartmentIdAsync(identity.DepartmentId); + var audit = NewAuditEvent(identity.DepartmentId, userId, AuditLogTypes.DepartmentBillingIdentityChanged, ipAddress, userAgent); + if (existing != null) audit.Before = Snapshot(existing); + identity.UpdatedOn = DateTime.UtcNow; + identity.UpdatedByUserId = userId; + var saved = await _identities.UpsertAsync(identity, cancellationToken); + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + return saved; + } + + #endregion + + #region Helpers + + private async Task LoadChildrenAsync(Invoice invoice) + { + invoice.LineItems = (await _lineItems.GetByInvoiceIdAsync(invoice.InvoiceId, invoice.DepartmentId))?.ToList() ?? new List(); + invoice.Payments = (await _payments.GetByInvoiceIdAsync(invoice.InvoiceId, invoice.DepartmentId))?.ToList() ?? new List(); + } + + private async Task RequireDraftAsync(string invoiceId, int departmentId) + { + var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + if (invoice.Status != (int)InvoiceStatus.Draft) throw new InvalidOperationException("invoicing_invoice_not_draft"); + return invoice; + } + + /// Re-derives AmountPaid and the Sent / PartiallyPaid / Paid / Overdue status from the effective payments. + private async Task ApplyPaymentStateAsync(Invoice invoice, string userId, DateTime now, CancellationToken cancellationToken) + { + var payments = (await _payments.GetByInvoiceIdAsync(invoice.InvoiceId, invoice.DepartmentId))?.ToList() ?? new List(); + invoice.AmountPaid = RoundMoney(payments.Sum(x => x.EffectiveAmount)); + invoice.Status = DeriveStatus(invoice, now); + invoice.PaidOn = invoice.Status == (int)InvoiceStatus.Paid ? (invoice.PaidOn ?? payments.Where(x => x.EffectiveAmount > 0).Select(x => (DateTime?)x.PaidOn).DefaultIfEmpty(now).Max()) : null; + invoice.EditedOn = now; + invoice.EditedByUserId = userId; + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); + } + + /// Paid when the balance is settled; PartiallyPaid when something is paid; otherwise Overdue if past due, else Sent. Void and Draft never change here. + public static int DeriveStatus(Invoice invoice, DateTime now) + { + if (invoice.Status is (int)InvoiceStatus.Void or (int)InvoiceStatus.Draft) return invoice.Status; + if (invoice.Total > 0 && invoice.AmountPaid >= invoice.Total) return (int)InvoiceStatus.Paid; + if (invoice.AmountPaid > 0) return (int)InvoiceStatus.PartiallyPaid; + return invoice.DueOn.HasValue && invoice.DueOn.Value < now ? (int)InvoiceStatus.Overdue : (int)InvoiceStatus.Sent; + } + + /// Minutes between the unit's first OnScene state on the call and its next state; falls back to the call window when the unit never reported OnScene (plan decision 9). + public static int OnSceneMinutes(IReadOnlyList unitStates, DateTime callLoggedOn, DateTime fallbackEnd) + { + var ordered = unitStates.OrderBy(x => x.Timestamp).ToList(); + var onScene = ordered.FirstOrDefault(x => x.State == (int)UnitStateTypes.OnScene); + DateTime start, end; + if (onScene != null) + { + start = onScene.Timestamp; + var next = ordered.FirstOrDefault(x => x.Timestamp > onScene.Timestamp && x.State != (int)UnitStateTypes.OnScene); + end = next?.Timestamp ?? fallbackEnd; + } + else + { + start = callLoggedOn; + end = fallbackEnd; + } + var minutes = (int)Math.Ceiling((end - start).TotalMinutes); + return minutes < 0 ? 0 : minutes; + } + + /// Billable hours after the item's minimum and rounding increment (plan decision 9). + public static decimal ApplyRounding(int minutes, RateCardItem item) + { + var billable = Math.Max(minutes, item.MinimumMinutes ?? 0); + if (item.RoundingMinutes.HasValue && item.RoundingMinutes.Value > 0) + billable = (int)Math.Ceiling(billable / (decimal)item.RoundingMinutes.Value) * item.RoundingMinutes.Value; + return Math.Round(billable / 60m, 4, MidpointRounding.AwayFromZero); + } + + public static decimal RoundMoney(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + + private static string NormalizeCurrency(string currency) => + string.IsNullOrWhiteSpace(currency) ? "USD" : currency.Trim().ToUpperInvariant().Substring(0, Math.Min(3, currency.Trim().Length)); + + private static void ValidatePercent(decimal? value, string name) + { + if (value.HasValue && (value.Value < 0 || value.Value > 100)) throw new ArgumentException($"{name} must be between 0 and 100.", name); + } + + private static void ValidateTaxComponents(string json) + { + var components = ParseTaxComponents(json); + if (components.Any(x => string.IsNullOrWhiteSpace(x.Name) || x.RatePercent < 0 || x.RatePercent > 100)) + throw new ArgumentException("Every tax component needs a name and a rate between 0 and 100.", nameof(json)); + } + + public static List ParseTaxComponents(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try + { + return JsonConvert.DeserializeObject>(json)?.Where(x => x != null).ToList() ?? new List(); + } + catch (JsonException) + { + throw new ArgumentException("TaxComponentsJson is not a valid component list.", nameof(json)); + } + } + + /// Publishes the lifecycle trigger through the domain outbox (plan decision 22). Payload = identifiers, status, amounts, dates; contact name only when the row is not protected. + private async Task PublishAsync(Invoice invoice, WorkflowTriggerEventType trigger, InvoicePayment payment = null, int? oldStatus = null, CancellationToken cancellationToken = default) + { + string contactName = null; + if (!invoice.IsProtected) + { + try + { + var contact = await _contactsService.GetContactByIdAsync(invoice.ContactId); + contactName = contact == null ? null : (string.IsNullOrWhiteSpace(contact.CompanyName) ? $"{contact.FirstName} {contact.LastName}".Trim() : contact.CompanyName); + } + catch (Exception ex) { Logging.LogException(ex, "Invoice workflow payload: contact name could not be read."); } + } + + try + { + await _outbox.EnqueueAsync(invoice.DepartmentId, InvoiceWorkflowPayload.Producer, new DomainEventEnvelope + { + EventName = trigger.ToString(), + AggregateType = "Invoice", + AggregateId = invoice.InvoiceId, + AggregateVersion = 0, + Trigger = trigger, + OccurredOn = DateTime.UtcNow, + CorrelationId = invoice.InvoiceId, + Payload = new + { + invoice.InvoiceId, invoice.InvoiceNumber, invoice.Status, invoice.ContactId, + ContactName = invoice.IsProtected ? ProtectedDataEnvelope.RedactionValue : contactName, + invoice.Currency, invoice.SubTotal, invoice.DiscountAmount, invoice.TaxAmount, invoice.Total, invoice.AmountPaid, invoice.Balance, + invoice.IssuedOn, invoice.DueOn, invoice.SentOn, invoice.PaidOn, + PaymentAmount = payment?.Amount, PaymentMethod = payment == null ? null : ((InvoicePaymentMethods)payment.Method).ToString(), PaymentId = payment?.InvoicePaymentId, + OldStatus = oldStatus + } + }, cancellationToken); + } + catch (Exception ex) + { + // A failed publish must never undo a committed money transition; it is logged and visible in the outbox health. + Logging.LogException(ex, $"Invoice {invoice.InvoiceId} {trigger} could not be published."); + } + } + + private static string Snapshot(T entity) + { + var clone = entity.CloneJson(); + switch (clone) + { + case Invoice invoice: invoice.LineItems = null; invoice.Payments = null; break; + case RateCard card: card.Items = null; break; + } + return clone.CloneJsonToString(); + } + + private static AuditEvent NewAuditEvent(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent) + { + return new AuditEvent + { + DepartmentId = departmentId, + UserId = userId, + Type = type, + Successful = true, + IpAddress = ipAddress, + UserAgent = userAgent, + ServerName = Environment.MachineName + }; + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Invoicing/StripeConnectEndpointProbe.cs b/Core/Resgrid.Services/Invoicing/StripeConnectEndpointProbe.cs new file mode 100644 index 000000000..6694c653e --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/StripeConnectEndpointProbe.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.Providers; +using Stripe; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Lists the platform account's webhook endpoints with the Connect platform key (plan B2.5a). Uses its own + /// StripeClient so the SaaS billing path's global Stripe configuration is never touched. + /// + public sealed class StripeConnectEndpointProbe : IStripeConnectEndpointProbe + { + public async Task IsEndpointRegisteredAsync(string expectedUrl, bool liveMode, IReadOnlyCollection requiredEvents) + { + if (string.IsNullOrWhiteSpace(Config.PaymentConnectConfig.StripeSecretKey) || string.IsNullOrWhiteSpace(expectedUrl)) + return null; + + var client = new StripeClient(Config.PaymentConnectConfig.StripeSecretKey); + var service = new WebhookEndpointService(client); + var endpoints = await service.ListAsync(new WebhookEndpointListOptions { Limit = 100 }); + + var expected = Normalize(expectedUrl); + foreach (var endpoint in endpoints) + { + if (endpoint == null || endpoint.Deleted == true) + continue; + if (!string.Equals(Normalize(endpoint.Url), expected, StringComparison.OrdinalIgnoreCase)) + continue; + if (!string.Equals(endpoint.Status, "enabled", StringComparison.OrdinalIgnoreCase)) + continue; + if (endpoint.Livemode != liveMode) + continue; + + var events = endpoint.EnabledEvents ?? new List(); + if (events.Contains("*") || requiredEvents.All(e => events.Contains(e, StringComparer.OrdinalIgnoreCase))) + return true; + } + + return false; + } + + private static string Normalize(string url) + { + return (url ?? string.Empty).Trim().TrimEnd('/'); + } + } +} diff --git a/Core/Resgrid.Services/MessageService.cs b/Core/Resgrid.Services/MessageService.cs index e7fed08e4..fb21e76b0 100644 --- a/Core/Resgrid.Services/MessageService.cs +++ b/Core/Resgrid.Services/MessageService.cs @@ -7,6 +7,7 @@ using Resgrid.Model; using Resgrid.Model.Queue; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -126,6 +127,16 @@ public async Task> GetSentMessagesByUserIdAsync(string userId) return new List(); } + public async Task> GetAllMessagesForDepartmentAsync(int departmentId) + { + var items = await _messageRepository.GetMessagesByDepartmentIdAsync(departmentId); + + if (items != null && items.Any()) + return items.OrderByDescending(x => x.SentOn).ToList(); + + return new List(); + } + public async Task GetUnreadMessagesCountByUserIdAsync(string userId) { return await _messageRepository.GetUnreadMessageCountAsync(userId); @@ -148,7 +159,30 @@ private static bool IsActiveInboxMessage(Message message) public async Task MarkMessagesAsDeletedAsync(string userId, List messageIds, CancellationToken cancellationToken = default(CancellationToken)) { - return await _messageRepository.UpdateRecievedMessagesAsDeletedAsync(userId, messageIds); + var updated = await _messageRepository.UpdateRecievedMessagesAsDeletedAsync(userId, messageIds); + + // The bulk update never passes through SaveMessageAsync, so the parents are re-projected here: the + // projection's participant list is what scopes the message to its viewers in the index, and a recipient + // who deleted it from their inbox would otherwise keep finding it until the next rebuild. + if (updated && _searchProjections != null && messageIds != null) + { + foreach (var id in messageIds.Select(v => int.TryParse(v, out var parsed) ? parsed : 0).Where(v => v > 0).Distinct()) + { + cancellationToken.ThrowIfCancellationRequested(); + await ReprojectMessageAsync(id, cancellationToken); + } + } + + return updated; + } + + private async Task ReprojectMessageAsync(int messageId, CancellationToken cancellationToken) + { + if (_searchProjections == null || messageId <= 0) + return; + var parent = await GetMessageByIdAsync(messageId); + if (parent != null) + await _searchProjections.Value.ProjectMessageAsync(parent, cancellationToken); } public async Task MarkMessagesAsReadAsync(string userId, List messageIds, CancellationToken cancellationToken = default(CancellationToken)) @@ -173,7 +207,9 @@ private async Task EnsureRecipientOwnerAsync(MessageRecipient recipient) var message = await GetMessageRecipientByMessageAndUserAsync(messageId, userId); message.IsDeleted = true; - return await SaveMessageRecipientAsync(message, cancellationToken); + var saved = await SaveMessageRecipientAsync(message, cancellationToken); + await ReprojectMessageAsync(messageId, cancellationToken); + return saved; } public async Task SendMessageAsync(Message message, string sendersName, int departmentId, bool broadcastSingle = true, CancellationToken cancellationToken = default(CancellationToken)) @@ -258,7 +294,9 @@ private async Task EnsureRecipientOwnerAsync(MessageRecipient recipient) await _messageRecipientRepository.DeleteAsync(mr, cancellationToken); } - await _messageRepository.DeleteAsync(m, cancellationToken); + var deleted = await _messageRepository.DeleteAsync(m, cancellationToken); + if (deleted && _searchProjections != null && m.DepartmentId.HasValue) + await _searchProjections.Value.RemoveAsync(m.DepartmentId.Value, SearchEntityTypes.Message, m.MessageId.ToString(), cancellationToken); } var messageRecipients = await _messageRecipientRepository.GetMessageRecipientByUserAsync(userId); diff --git a/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs b/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs index bdeacd01b..af6922fc9 100644 --- a/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs +++ b/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs @@ -300,11 +300,13 @@ private async Task RebuildProjectionsAsync(int departmentId, CancellationTo foreach (var member in members) { cancellationToken.ThrowIfCancellationRequested(); - if (member.IsDeleted || string.IsNullOrWhiteSpace(member.UserId)) continue; + // Same membership rule as DepartmentsService.SaveDepartmentMemberAsync: disabled and hidden members are + // off the personnel list, so they stay out of the index too (SoftDeleteStaleAsync retires their row). + if (member.IsDeleted || member.IsDisabled.GetValueOrDefault() || member.IsHidden.GetValueOrDefault() || string.IsNullOrWhiteSpace(member.UserId)) continue; profiles.TryGetValue(member.UserId, out var profile); if (profile == null) continue; groups.TryGetValue(member.UserId, out var group); - var p = await _projectionService.BuildPersonnelAsync(departmentId, profile, group?.DepartmentGroupId, member.IsActive && !member.IsDeleted); + var p = await _projectionService.BuildPersonnelAsync(departmentId, profile, group?.DepartmentGroupId, member.IsActive); if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } } return n; @@ -349,23 +351,18 @@ private async Task RebuildProjectionsAsync(int departmentId, CancellationTo count += await Family(departmentId, SearchEntityTypes.Message, async () => { - // Messages have no department-wide list; walk the members' sent and inbox folders once, de-duplicated. - var members = await _departments.GetAllMembersForDepartmentAsync(departmentId) ?? new List(); + // One department-scoped read (M0137 owner column) instead of two folder queries per member. A message + // that was never attributed to a department has no projection either way: BuildMessageAsync needs + // the owner, and the per-member walk this replaces filtered on the same column. var seen = new HashSet(); var n = 0; - foreach (var member in members.Where(m => !m.IsDeleted && !string.IsNullOrWhiteSpace(m.UserId))) + foreach (var message in await _messages.GetAllMessagesForDepartmentAsync(departmentId) ?? new List()) { cancellationToken.ThrowIfCancellationRequested(); - var folders = new List(); - try { folders.AddRange(await _messages.GetSentMessagesByUserIdAsync(member.UserId) ?? new List()); } catch (Exception ex) { Logging.LogException(ex); } - try { folders.AddRange(await _messages.GetInboxMessagesByUserIdAsync(member.UserId) ?? new List()); } catch (Exception ex) { Logging.LogException(ex); } - foreach (var message in folders) - { - if (message == null || !message.DepartmentId.HasValue || message.DepartmentId.Value != departmentId || message.IsDeleted || !seen.Add(message.MessageId)) - continue; - var p = await _projectionService.BuildMessageAsync(message); - if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } - } + if (message == null || message.IsDeleted || !seen.Add(message.MessageId)) + continue; + var p = await _projectionService.BuildMessageAsync(message); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } } return n; }, started, cancellationToken); diff --git a/Core/Resgrid.Services/Search/SystemActionCatalog.cs b/Core/Resgrid.Services/Search/SystemActionCatalog.cs index 9d507ccad..fb0948d04 100644 --- a/Core/Resgrid.Services/Search/SystemActionCatalog.cs +++ b/Core/Resgrid.Services/Search/SystemActionCatalog.cs @@ -32,6 +32,7 @@ public static class SystemActionCatalog private const string Record = "Record"; private const string Checklist = "Checklist"; private const string WorkOrder = "WorkOrder"; + private const string Invoicing = "Invoicing"; private const string Group = "Group"; private const string Protocols = "Protocols"; private const string Forms = "Forms"; @@ -137,6 +138,13 @@ private static SystemActionDefinition Act(string key, string title, string descr Nav("work-orders", "Work Orders", "Maintenance and repair work orders", "/User/WorkOrders", new[] { "maintenance", "repair", "service", "fleet", "defect" }, WorkOrder, View, SystemActionModules.Maintenance, FeatureFlagKeys.MaintenanceWorkOrders), Act("new-work-order", "New Work Order", "Open a maintenance work order", "/User/WorkOrders/New", SystemActionCategories.Create, new[] { "repair", "defect", "maintenance request" }, WorkOrder, Update, SystemActionModules.Maintenance, FeatureFlagKeys.MaintenanceWorkOrders), + // ---- Business operations (Workforce & Business Operations plan, Phase B) + Nav("invoices", "Invoices", "Customer invoices and payments", "/User/Invoicing", new[] { "invoice", "billing", "accounts receivable", "customer", "payment" }, Invoicing, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.CustomerInvoicing), + Act("new-invoice", "New Invoice", "Draft an invoice for a customer", "/User/Invoicing/New", SystemActionCategories.Create, new[] { "invoice", "bill", "charge" }, Invoicing, Create, SystemActionModules.BusinessOperations, FeatureFlagKeys.CustomerInvoicing), + Nav("rate-cards", "Rate Cards", "Billing rates for units, personnel and fees", "/User/Invoicing/RateCards", new[] { "rates", "pricing", "hourly", "fees" }, Invoicing, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.CustomerInvoicing), + Nav("invoice-aging", "Accounts Receivable Aging", "Outstanding invoice balances by age", "/User/Invoicing/Aging", new[] { "aging", "overdue", "receivables", "outstanding" }, Invoicing, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.CustomerInvoicing), + Act("billing-settings", "Billing Settings", "Legal name, remit-to address and tax registrations printed on invoices", "/User/Invoicing/Settings", SystemActionCategories.Manage, new[] { "remit to", "tax id", "vat", "invoice footer" }, Invoicing, Update, SystemActionModules.BusinessOperations, FeatureFlagKeys.CustomerInvoicing), + // ---- Messaging / chat Nav("inbox", "Inbox", "Your messages inbox", "/User/Messages/Inbox", new[] { "messages", "mail", "read" }, Messages, View, SystemActionModules.Messaging), Nav("outbox", "Sent Messages", "Messages you sent", "/User/Messages/Outbox", new[] { "sent", "outbox" }, Messages, View, SystemActionModules.Messaging), diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs index a3497083a..7742a78cd 100644 --- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs @@ -170,12 +170,11 @@ public async Task SearchAsync(UnifiedSearchRequest request, } } + // One sequence, index hits then the records federation, paged as a whole: special-casing the first page + // dropped the Records family from every later page. var skip = Math.Max(0, request.Skip); var take = Math.Max(1, Math.Min(100, request.Take)); - var page = authorized.Skip(skip).Take(take).ToList(); - if (page.Count < take && skip == 0) - page.AddRange(recordHits.Take(take - page.Count)); - result.Hits = page; + result.Hits = authorized.Concat(recordHits).Skip(skip).Take(take).ToList(); result.Truncated = truncated; // Totals only when they can be proven from authorized results (plan 2026-08-15 correction). @@ -312,8 +311,10 @@ private static UnifiedSearchHit Map(GlobalSearchHit hit) var loaded = (await _records.GetProjectionsByIdsAsync(principal.DepartmentId, ids) ?? new List()) .ToDictionary(p => p.RmsRecordSearchProjectionId, StringComparer.OrdinalIgnoreCase); + // Only record-source hits were loaded above, so only those can be judged: any other source type reaching + // here is not a dropped hit, and counting it as one would null the totals for every query in the department. var dropped = 0; - foreach (var hit in search.Hits) + foreach (var hit in search.Hits.Where(h => h.SourceType == recordSource)) { cancellationToken.ThrowIfCancellationRequested(); if (!loaded.TryGetValue(hit.SourceId ?? string.Empty, out var projection) || !await _recordsAuthorization.CanUserViewRecordAsync(principal.UserId, hit.SourceId, principal.DepartmentId)) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 4527d50b1..57095bc6d 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -31,6 +31,17 @@ protected override void Load(ContainerBuilder builder) var scope = context.Resolve(); return (Func)(() => scope.ResolveNamed("readiness-billing-client")); }).InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase B (decision 42): add-on billing proxy and entitlement gate. + builder.Register(_ => new RestClient(new RestClientOptions(SystemBehaviorConfig.BillingApiBaseUrl) { Timeout = TimeSpan.FromSeconds(10) }, + configureSerialization: serializer => serializer.UseNewtonsoftJson())).Named("business-operations-billing-client").SingleInstance(); + builder.RegisterType().As() + .WithParameter((parameter, _) => parameter.ParameterType == typeof(Func), (_, context) => + { + var scope = context.Resolve(); + return (Func)(() => scope.ResolveNamed("business-operations-billing-client")); + }).InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -136,6 +147,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase B2 (scaffold 2026-09-18): webhook health read for the v4 Health endpoint. + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index 2bacdcd71..60e4165f2 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -778,7 +778,7 @@ public async Task> GetCurrentPaymentAddonsForDepartmentAsync( public Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType, bool bypassCache = false) { - if (!bypassCache && planAddonType == PlanAddonTypes.ReadinessPro && Config.SystemBehaviorConfig.CacheEnabled && + if (!bypassCache && (planAddonType == PlanAddonTypes.ReadinessPro || planAddonType == PlanAddonTypes.BusinessOperations) && Config.SystemBehaviorConfig.CacheEnabled && !string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) return _cacheProvider.RetrieveAsync($"AddonPlansByType_{(int)planAddonType}", () => LoadAddonPlansByTypeAsync(planAddonType), TimeSpan.FromMinutes(5)); @@ -1166,7 +1166,7 @@ public async Task GetActivePTTStripeSubscriptionA public async Task ModifyPTTAddonSubscriptionAsync(string stripeCustomerId, long quantity, PlanAddon planAddon) { - if (planAddon?.AddonType == (int)PlanAddonTypes.ReadinessPro) + if (planAddon?.AddonType == (int)PlanAddonTypes.ReadinessPro || planAddon?.AddonType == (int)PlanAddonTypes.BusinessOperations) return false; if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) @@ -1477,7 +1477,7 @@ public async Task ChangePaddleSubscriptionAsync(string paddleCustomerId, s public async Task ModifyPaddlePTTAddonSubscriptionAsync(string paddleCustomerId, long quantity, PlanAddon planAddon) { - if (planAddon?.AddonType == (int)PlanAddonTypes.ReadinessPro) + if (planAddon?.AddonType == (int)PlanAddonTypes.ReadinessPro || planAddon?.AddonType == (int)PlanAddonTypes.BusinessOperations) return false; if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index 3ff39320c..42ffee9f8 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -533,7 +533,11 @@ public async Task> GetAllRolesForDepartmentAsync(int departmentId var saved = await _unitsRepository.SaveOrUpdateAsync(unit, cancellationToken); if (saved != null) + { touchedDepartmentIds.Add(saved.DepartmentId); + // Direct repository save: the projection carries the station group, so refresh it as SaveUnitAsync does. + if (_searchProjections != null) await _searchProjections.Value.ProjectUnitAsync(saved, cancellationToken); + } } // Un-stationing a unit moves it out of its station group's bucket in both unit matrices; diff --git a/Core/Resgrid.Services/UserProfileService.cs b/Core/Resgrid.Services/UserProfileService.cs index 982040c47..279387675 100644 --- a/Core/Resgrid.Services/UserProfileService.cs +++ b/Core/Resgrid.Services/UserProfileService.cs @@ -1,4 +1,5 @@ -using Resgrid.Model; +using Resgrid.Framework; +using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -20,15 +21,18 @@ public class UserProfileService : IUserProfileService private readonly IUserProfilesRepository _userProfileRepository; private readonly ICacheProvider _cacheProvider; private readonly IChatbotIdentityRepository _chatbotIdentityRepository; + private readonly IDepartmentMembersRepository _departmentMembersRepository; private readonly Lazy _searchProjections; public UserProfileService(IUserProfilesRepository userProfileRepository, ICacheProvider cacheProvider, - IChatbotIdentityRepository chatbotIdentityRepository, Lazy searchProjections = null) + IChatbotIdentityRepository chatbotIdentityRepository, IDepartmentMembersRepository departmentMembersRepository, + Lazy searchProjections = null) { _userProfileRepository = userProfileRepository; _cacheProvider = cacheProvider; _chatbotIdentityRepository = chatbotIdentityRepository; + _departmentMembersRepository = departmentMembersRepository; _searchProjections = searchProjections; } @@ -140,10 +144,39 @@ public async Task> GetAllProfilesForDepartmentIn ClearUserProfileFromCache(savedProfile.UserId); ClearAllUserProfilesFromCache(DepartmentId); - if (_searchProjections != null && DepartmentId > 0) await _searchProjections.Value.ProjectPersonnelAsync(DepartmentId, savedProfile, null, null, cancellationToken); + await ProjectProfileAsync(DepartmentId, savedProfile, cancellationToken); return savedProfile; } + /// + /// A profile row is shared by every department the user belongs to, so the personnel projection is refreshed in + /// each live membership (not deleted, disabled or hidden: the same rule DepartmentsService applies), otherwise + /// the other departments keep the old name until their next rebuild. The caller's department is the fallback + /// when the memberships cannot be read. + /// + private async Task ProjectProfileAsync(int callerDepartmentId, UserProfile savedProfile, CancellationToken cancellationToken) + { + if (_searchProjections == null || savedProfile == null || string.IsNullOrWhiteSpace(savedProfile.UserId)) + return; + + List memberships = null; + try { memberships = (await _departmentMembersRepository.GetAllDepartmentMemberByUserIdAsync(savedProfile.UserId))?.ToList(); } + catch (Exception ex) { Logging.LogException(ex, $"Search projection for profile {savedProfile.UserId}: memberships could not be read; projecting the caller's department only."); } + + if (memberships == null) + { + if (callerDepartmentId > 0) + await _searchProjections.Value.ProjectPersonnelAsync(callerDepartmentId, savedProfile, null, null, cancellationToken); + return; + } + + foreach (var membership in memberships.Where(m => m != null && m.DepartmentId > 0 && !m.IsDeleted && !m.IsDisabled.GetValueOrDefault() && !m.IsHidden.GetValueOrDefault())) + { + cancellationToken.ThrowIfCancellationRequested(); + await _searchProjections.Value.ProjectPersonnelAsync(membership.DepartmentId, savedProfile, null, membership.IsActive, cancellationToken); + } + } + private async Task RemoveSmsChatbotIdentitiesAsync(string userId, CancellationToken cancellationToken) { var identities = await _chatbotIdentityRepository.GetAllByUserIdAsync(userId); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index f584f6fc0..a7d0dd8a0 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -80,6 +80,19 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve { switch (eventType) { + case WorkflowTriggerEventType.InvoiceCreated: + case WorkflowTriggerEventType.InvoiceSent: + case WorkflowTriggerEventType.InvoicePaymentRecorded: + case WorkflowTriggerEventType.InvoicePaid: + case WorkflowTriggerEventType.InvoiceOverdue: + case WorkflowTriggerEventType.InvoiceVoided: + case WorkflowTriggerEventType.InvoicePaymentRefunded: + case WorkflowTriggerEventType.InvoicePaymentDisputed: + obj["invoice"] = new ScriptObject { ["id"] = "7a1d2c3b-4e5f-4a6b-8c7d-9e0f1a2b3c4d", ["number"] = 1042, ["status"] = 1, ["contact_id"] = "c9e8d7f6-5a4b-4c3d-9e2f-1a0b9c8d7e6f", ["contact_name"] = "Acme Logistics", ["currency"] = "USD", ["sub_total"] = 1250.00m, ["discount_amount"] = 125.00m, ["tax_amount"] = 90.00m, ["total"] = 1215.00m, ["amount_paid"] = 0m, ["balance"] = 1215.00m, ["issued_on"] = "2026-09-18T15:00:00Z", ["due_on"] = "2026-10-18T15:00:00Z", ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Invoicing/View/7a1d2c3b-4e5f-4a6b-8c7d-9e0f1a2b3c4d" }; + var sampleInvoice = (ScriptObject)obj["invoice"]; + foreach (var variable in Resgrid.Model.Invoicing.InvoiceWorkflowPayload.Variables) if (!sampleInvoice.ContainsKey(variable.Variable)) sampleInvoice[variable.Variable] = null; + if (eventType is WorkflowTriggerEventType.InvoicePaymentRecorded or WorkflowTriggerEventType.InvoicePaid or WorkflowTriggerEventType.InvoicePaymentRefunded or WorkflowTriggerEventType.InvoicePaymentDisputed) { sampleInvoice["payment_amount"] = 1215.00m; sampleInvoice["payment_method"] = "Online"; sampleInvoice["payment_id"] = "0b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"; sampleInvoice["amount_paid"] = 1215.00m; sampleInvoice["balance"] = 0m; sampleInvoice["status"] = 3; } + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 23d945830..0a10e5583 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -417,6 +417,23 @@ public async Task BuildContextAsync( } break; } + case WorkflowTriggerEventType.InvoiceCreated: + case WorkflowTriggerEventType.InvoiceSent: + case WorkflowTriggerEventType.InvoicePaymentRecorded: + case WorkflowTriggerEventType.InvoicePaid: + case WorkflowTriggerEventType.InvoiceOverdue: + case WorkflowTriggerEventType.InvoiceVoided: + case WorkflowTriggerEventType.InvoicePaymentRefunded: + case WorkflowTriggerEventType.InvoicePaymentDisputed: + { + var invoiceEvent = TryDeserialize(eventPayloadJson); + var invoicePayload = invoiceEvent?.Payload ?? new JObject(); var invoice = new ScriptObject(); + foreach (var pair in Resgrid.Model.Invoicing.InvoiceWorkflowPayload.Variables) invoice[pair.Variable] = ToScriptValue(invoicePayload[pair.Property]); + var invoiceId = invoicePayload["InvoiceId"]?.Type == JTokenType.String ? invoicePayload["InvoiceId"].Value() : null; + invoice["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Invoicing/View/{invoiceId}"; + scriptObject["invoice"] = invoice; + break; + } case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index cc3ffa6a0..ba5588e6b 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1670,7 +1670,7 @@ public static void AddRecordClaims(ClaimsIdentity identity, bool isAdmin, List

SendReportDeliveryMail(string email, string subject, str return false; } + public async Task SendInvoiceMail(string email, string subject, string messageBody, string sentOn, + string invoiceLabel, string attachmentFilename, byte[] attachmentData, string invoiceUrl, string payUrl, DepartmentEmailBranding branding) + { + if (attachmentData == null || String.IsNullOrWhiteSpace(email)) + return false; + + var templateModel = new Dictionary + { + { "title", subject }, + { "invoice_label", invoiceLabel }, + { "body", HtmlToTextHelper.ConvertHtml(messageBody) }, + { "attachment_name", attachmentFilename }, + { "attachment_size", StringHelpers.GetSizeInMemory(attachmentData.LongLength) }, + { "attachment_type", "PDF" }, + { "invoice_links", String.IsNullOrWhiteSpace(invoiceUrl) ? Array.Empty>() : new[] { new Dictionary { { "url", invoiceUrl } } } }, + { "pay_links", String.IsNullOrWhiteSpace(payUrl) ? Array.Empty>() : new[] { new Dictionary { { "url", payUrl } } } }, + { "timestamp", sentOn } + }; + + AddDepartmentBranding(templateModel, branding); + + try + { + var template = Mustachio.Parser.Parse(GetTempate("InvoiceDelivery.html")); + var content = template(templateModel); + + Email newEmail = new Email(); + newEmail.HtmlBody = content; + newEmail.Sender = DONOTREPLY_EMAIL; + newEmail.To.Add(email); + newEmail.AttachmentName = attachmentFilename; + newEmail.AttachmentData = attachmentData; + newEmail.AttachmentContentType = "application/pdf"; + newEmail.From = DONOTREPLY_EMAIL; + newEmail.Subject = subject; + + return await _emailSender.Send(newEmail); + } + catch (Exception) + { + } + + return false; + } + public async Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content) { // The model is built before the try below, so without this a null content would throw past diff --git a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj index 85d3305d8..0838eb735 100644 --- a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj +++ b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj @@ -28,6 +28,7 @@ + diff --git a/Providers/Resgrid.Providers.Email/Template/InvoiceDelivery.html b/Providers/Resgrid.Providers.Email/Template/InvoiceDelivery.html new file mode 100644 index 000000000..bbb6e48bf --- /dev/null +++ b/Providers/Resgrid.Providers.Email/Template/InvoiceDelivery.html @@ -0,0 +1,473 @@ + + + + + + Your scheduled Resgrid report is ready + + + + + {{invoice_label}} is attached. + + + + + + + + + + + + diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs new file mode 100644 index 000000000..339de0ec5 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs @@ -0,0 +1,135 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + ///

+ /// Workforce & Business Operations plan, Phase B (B1): customer billing profiles (a Contact becomes billable), + /// simple per-call rate cards, and the department's own billing identity that prints on every invoice. + /// Registry M0209, the next physical number under the no-gaps rule. Every table carries the ADP row marker + /// (IsProtected / ProtectedCatalogVersion) from creation; the Phase B catalog (26) is registered by M0212. + /// Guarded for safe retry. + /// + [Migration(209)] + public class M0209_AddCustomerBillingAndRateCards : Migration + { + public override void Up() + { + if (!Schema.Table("CustomerBillingProfiles").Exists()) + { + Create.Table("CustomerBillingProfiles") + .WithColumn("CustomerBillingProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("BillingEmail").AsString(500).Nullable() + .WithColumn("BillingAddressId").AsInt32().Nullable() + .WithColumn("UseContactMailingAddress").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("TermsNetDays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("TaxExempt").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("TaxRate").AsDecimal(9, 4).Nullable() + .WithColumn("TaxComponentsJson").AsString(int.MaxValue).Nullable() + .WithColumn("DefaultRateCardId").AsString(36).Nullable() + .WithColumn("DefaultDiscountPercent").AsDecimal(9, 4).Nullable() + .WithColumn("DefaultRateScheduleId").AsString(36).Nullable() + .WithColumn("PurchaseOrderRequired").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("Active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_CustomerBillingProfiles_Department").OnTable("CustomerBillingProfiles") + .OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + + // One live billing profile per contact; a soft-deleted row does not block a replacement. + Execute.Sql("CREATE UNIQUE INDEX [UX_CustomerBillingProfiles_Contact_Live] ON [CustomerBillingProfiles] ([ContactId]) WHERE [IsDeleted] = 0;"); + } + + if (!Schema.Table("RateCards").Exists()) + { + Create.Table("RateCards") + .WithColumn("RateCardId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("IsDefault").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RateCards_Department").OnTable("RateCards") + .OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + } + + if (!Schema.Table("RateCardItems").Exists()) + { + Create.Table("RateCardItems") + .WithColumn("RateCardItemId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("RateCardId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ItemType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Rate").AsDecimal(18, 4).NotNullable().WithDefaultValue(0) + .WithColumn("UnitLabel").AsString(50).Nullable() + .WithColumn("MinimumMinutes").AsInt32().Nullable() + .WithColumn("RoundingMinutes").AsInt32().Nullable() + .WithColumn("MinimumCharge").AsDecimal(18, 4).Nullable() + .WithColumn("UnitTypeFilter").AsString(100).Nullable() + .WithColumn("PersonnelRoleIdFilter").AsInt32().Nullable() + .WithColumn("Taxable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RateCardItems_RateCard").OnTable("RateCardItems") + .OnColumn("RateCardId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("SortOrder").Ascending(); + Create.Index("IX_RateCardItems_Department").OnTable("RateCardItems") + .OnColumn("DepartmentId").Ascending(); + } + + if (!Schema.Table("DepartmentBillingIdentities").Exists()) + { + Create.Table("DepartmentBillingIdentities") + .WithColumn("DepartmentId").AsInt32().NotNullable().PrimaryKey() + .WithColumn("LegalBusinessName").AsString(300).Nullable() + .WithColumn("RemitToAddressId").AsInt32().Nullable() + .WithColumn("TaxRegistrationNumber").AsString(100).Nullable() + .WithColumn("SecondaryTaxRegistrationNumber").AsString(100).Nullable() + .WithColumn("SamUei").AsString(50).Nullable() + .WithColumn("CageCode").AsString(20).Nullable() + .WithColumn("WorkersCompAccountNumber").AsString(100).Nullable() + .WithColumn("InvoiceFooterText").AsString(int.MaxValue).Nullable() + .WithColumn("OnlinePaymentsEnabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DefaultPaymentConnectionId").AsString(36).Nullable() + .WithColumn("AllowedPaymentMethodsCsv").AsString(200).Nullable() + .WithColumn("PayLinkExpiryDays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("ShowPayOnlineOnDocuments").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("UpdatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedByUserId").AsString(128).Nullable(); + } + } + + public override void Down() + { + if (Schema.Table("DepartmentBillingIdentities").Exists()) Delete.Table("DepartmentBillingIdentities"); + if (Schema.Table("RateCardItems").Exists()) Delete.Table("RateCardItems"); + if (Schema.Table("RateCards").Exists()) Delete.Table("RateCards"); + if (Schema.Table("CustomerBillingProfiles").Exists()) Delete.Table("CustomerBillingProfiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs new file mode 100644 index 000000000..ae1ff5c74 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs @@ -0,0 +1,136 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase B (B1): invoices, line items, payments and the per-department + /// invoice number sequence. Registry M0210. Money is decimal(18,2) for totals and decimal(18,4) for rates. + /// InvoicePayments already carries the Phase B2 online-payment columns (request id, provider, status, refund, + /// fee/net, payer e-mail, method summary, receipt URL) so M0212 adds no ALTER; Invoices.PlatformFeeAmount is + /// reserved and always null in v1. Every table carries the ADP row marker from creation. Guarded for safe retry. + /// + [Migration(210)] + public class M0210_AddInvoices : Migration + { + public override void Up() + { + if (!Schema.Table("Invoices").Exists()) + { + Create.Table("Invoices") + .WithColumn("InvoiceId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("InvoiceNumber").AsInt32().NotNullable() + .WithColumn("CustomerBillingProfileId").AsString(36).NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IssuedOn").AsDateTime2().Nullable() + .WithColumn("DueOn").AsDateTime2().Nullable() + .WithColumn("Currency").AsString(3).NotNullable().WithDefaultValue("USD") + .WithColumn("SubTotal").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("DiscountPercent").AsDecimal(9, 4).Nullable() + .WithColumn("DiscountAmount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("TaxAmount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("Total").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("AmountPaid").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("TaxComponentsJson").AsString(int.MaxValue).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("TermsText").AsString(int.MaxValue).Nullable() + .WithColumn("SentOn").AsDateTime2().Nullable() + .WithColumn("SentToEmail").AsString(500).Nullable() + .WithColumn("PaidOn").AsDateTime2().Nullable() + .WithColumn("VoidedOn").AsDateTime2().Nullable() + .WithColumn("VoidReason").AsString(1000).Nullable() + .WithColumn("PlatformFeeAmount").AsDecimal(18, 2).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + // The sequence table is the authority; this unique index is the backstop (decision 7). + Create.Index("UX_Invoices_Department_Number").OnTable("Invoices") + .OnColumn("DepartmentId").Ascending().OnColumn("InvoiceNumber").Ascending().WithOptions().Unique(); + Create.Index("IX_Invoices_Department_Status").OnTable("Invoices") + .OnColumn("DepartmentId").Ascending().OnColumn("Status").Ascending().OnColumn("IsDeleted").Ascending(); + Create.Index("IX_Invoices_Department_Contact").OnTable("Invoices") + .OnColumn("DepartmentId").Ascending().OnColumn("ContactId").Ascending(); + Create.Index("IX_Invoices_Department_Due").OnTable("Invoices") + .OnColumn("DepartmentId").Ascending().OnColumn("DueOn").Ascending(); + } + + if (!Schema.Table("InvoiceLineItems").Exists()) + { + Create.Table("InvoiceLineItems") + .WithColumn("InvoiceLineItemId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("InvoiceId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("RateCardItemId").AsString(36).Nullable() + .WithColumn("Description").AsString(1000).NotNullable() + .WithColumn("Quantity").AsDecimal(18, 4).NotNullable().WithDefaultValue(1) + .WithColumn("UnitRate").AsDecimal(18, 4).NotNullable().WithDefaultValue(0) + .WithColumn("Amount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("Taxable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_InvoiceLineItems_Invoice").OnTable("InvoiceLineItems") + .OnColumn("InvoiceId").Ascending().OnColumn("SortOrder").Ascending(); + Create.Index("IX_InvoiceLineItems_Department_Call").OnTable("InvoiceLineItems") + .OnColumn("DepartmentId").Ascending().OnColumn("CallId").Ascending(); + } + + if (!Schema.Table("InvoicePayments").Exists()) + { + Create.Table("InvoicePayments") + .WithColumn("InvoicePaymentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("InvoiceId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Amount").AsDecimal(18, 2).NotNullable() + .WithColumn("Method").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Reference").AsString(200).Nullable() + .WithColumn("GatewayTransactionId").AsString(200).Nullable() + .WithColumn("PaymentRequestId").AsString(36).Nullable() + .WithColumn("Provider").AsInt32().Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RefundedAmount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("ProviderFeeAmount").AsDecimal(18, 2).Nullable() + .WithColumn("NetAmount").AsDecimal(18, 2).Nullable() + .WithColumn("PayerEmail").AsString(500).Nullable() + .WithColumn("PaymentMethodSummary").AsString(100).Nullable() + .WithColumn("ReceiptUrl").AsString(2048).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("PaidOn").AsDateTime2().NotNullable() + .WithColumn("RecordedByUserId").AsString(128).Nullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_InvoicePayments_Invoice").OnTable("InvoicePayments") + .OnColumn("InvoiceId").Ascending().OnColumn("PaidOn").Ascending(); + Create.Index("IX_InvoicePayments_Department_Paid").OnTable("InvoicePayments") + .OnColumn("DepartmentId").Ascending().OnColumn("PaidOn").Ascending(); + Create.Index("IX_InvoicePayments_Gateway").OnTable("InvoicePayments") + .OnColumn("Provider").Ascending().OnColumn("GatewayTransactionId").Ascending(); + } + + if (!Schema.Table("InvoiceNumberSequences").Exists()) + { + Create.Table("InvoiceNumberSequences") + .WithColumn("DepartmentId").AsInt32().NotNullable().PrimaryKey() + .WithColumn("NextInvoiceNumber").AsInt32().NotNullable().WithDefaultValue(1); + } + } + + public override void Down() + { + if (Schema.Table("InvoiceNumberSequences").Exists()) Delete.Table("InvoiceNumberSequences"); + if (Schema.Table("InvoicePayments").Exists()) Delete.Table("InvoicePayments"); + if (Schema.Table("InvoiceLineItems").Exists()) Delete.Table("InvoiceLineItems"); + if (Schema.Table("Invoices").Exists()) Delete.Table("Invoices"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0211_SeedBusinessOperationsAddonAndFlags.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0211_SeedBusinessOperationsAddonAndFlags.cs new file mode 100644 index 000000000..76112af72 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0211_SeedBusinessOperationsAddonAndFlags.cs @@ -0,0 +1,70 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase B (B1, decision 42): the Business Operations add-on catalog row + /// (PlanAddonTypes 4, USD 250/month through Stripe: product prod_VHnlBsvKsSpqeP, live price price_0UHEA6qJFDZJcnkVnj0ZaAFw; + /// the test-mode price id is empty until a test product exists — Readiness Pro M0190 precedent), the + /// BusinessOperationsBillingAccounts table (mirror of + /// ReadinessProBillingAccounts, M0197), the operator master flag Business.Operations and the Phase B flag + /// Invoicing.CustomerInvoicing (both off), and the first FeatureFlagPrerequisites row in the repository: + /// Invoicing.CustomerInvoicing requires Business.Operations (plan decision 11). Registry M0211. Guarded for safe retry. + /// + [Migration(211)] + public class M0211_SeedBusinessOperationsAddonAndFlags : Migration + { + private const string BusinessOperationsAddonId = "8c2f0d6e-5b1a-4f2e-9d3c-7a6b5e4d3c2b"; + + public override void Up() + { + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [PlanAddons] WHERE [PlanAddonId] = '" + BusinessOperationsAddonId + "' OR [AddonType] = 4) " + + "INSERT INTO [PlanAddons] ([PlanAddonId], [AddonType], [Cost], [ExternalId], [TestExternalId]) " + + "VALUES ('" + BusinessOperationsAddonId + "', 4, 250, 'price_0UHEA6qJFDZJcnkVnj0ZaAFw', '');"); + + if (!Schema.Table("BusinessOperationsBillingAccounts").Exists()) + { + Create.Table("BusinessOperationsBillingAccounts") + .WithColumn("DepartmentId").AsInt32().NotNullable().PrimaryKey() + .WithColumn("Provider").AsString(20).NotNullable() + .WithColumn("CustomerId").AsString(100).NotNullable() + .WithColumn("PlanAddonId").AsString(36).NotNullable() + .WithColumn("PriceId").AsString(100).NotNullable() + .WithColumn("SubscriptionId").AsString(100).Nullable() + .WithColumn("CheckoutId").AsString(100).Nullable() + .WithColumn("CheckoutUrl").AsString(2048).Nullable() + .WithColumn("CheckoutExpiresOn").AsDateTime2().Nullable() + .WithColumn("CheckoutAttempt").AsString(36).Nullable() + .WithColumn("UpdatedOn").AsDateTime2().NotNullable(); + } + + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Business.Operations') " + + "INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally], [IsPermanent]) " + + "VALUES ('Business.Operations', 'Business Operations', 'Operator master toggle for the paid Business Operations add-on surfaces (customer invoicing, contractor billing, Cal OES MARS, workforce costing). Prerequisite of every paid Business Operations flag; the purchase itself is the Business Operations add-on. Seeded off.', 'Business', 0, 1);"); + + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Invoicing.CustomerInvoicing') " + + "INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) " + + "VALUES ('Invoicing.CustomerInvoicing', 'Customer invoicing', 'Billing profiles, rate cards, invoices, payments and accounts-receivable aging (Workforce & Business Operations plan, Phase B). Requires Business.Operations and the Business Operations add-on. Seeded off.', 'Business', 0);"); + + // Prerequisite edge: Invoicing.CustomerInvoicing requires Business.Operations to be on (RequiredValue null = enabled). + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [FeatureFlagPrerequisites] p " + + " JOIN [FeatureFlags] f ON f.[FeatureFlagId] = p.[FeatureFlagId] " + + " JOIN [FeatureFlags] r ON r.[FeatureFlagId] = p.[RequiredFeatureFlagId] " + + " WHERE f.[FlagKey] = 'Invoicing.CustomerInvoicing' AND r.[FlagKey] = 'Business.Operations') " + + "INSERT INTO [FeatureFlagPrerequisites] ([FeatureFlagId], [RequiredFeatureFlagId], [RequiredValue]) " + + "SELECT f.[FeatureFlagId], r.[FeatureFlagId], NULL FROM [FeatureFlags] f CROSS JOIN [FeatureFlags] r " + + "WHERE f.[FlagKey] = 'Invoicing.CustomerInvoicing' AND r.[FlagKey] = 'Business.Operations';"); + } + + public override void Down() + { + // Operator-owned rows (add-on catalog, rollout flags, prerequisite edge) are never removed on rollback; + // the billing-account table is dropped only when empty. + Execute.Sql("IF OBJECT_ID('[BusinessOperationsBillingAccounts]', 'U') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM [BusinessOperationsBillingAccounts]) DROP TABLE [BusinessOperationsBillingAccounts];"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs new file mode 100644 index 000000000..eaa39edda --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs @@ -0,0 +1,126 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0209 (Workforce & Business Operations plan, Phase B, B1): customer billing profiles, + /// rate cards and the department billing identity. Same number, lower-case names, guarded for safe retry. + /// + [Migration(209)] + public class M0209_AddCustomerBillingAndRateCardsPg : Migration + { + public override void Up() + { + if (!Schema.Table("customerbillingprofiles").Exists()) + { + Create.Table("customerbillingprofiles") + .WithColumn("customerbillingprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("billingemail").AsString(500).Nullable() + .WithColumn("billingaddressid").AsInt32().Nullable() + .WithColumn("usecontactmailingaddress").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("termsnetdays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("taxexempt").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("taxrate").AsDecimal(9, 4).Nullable() + .WithColumn("taxcomponentsjson").AsString(int.MaxValue).Nullable() + .WithColumn("defaultratecardid").AsString(36).Nullable() + .WithColumn("defaultdiscountpercent").AsDecimal(9, 4).Nullable() + .WithColumn("defaultratescheduleid").AsString(36).Nullable() + .WithColumn("purchaseorderrequired").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("notes").AsString(int.MaxValue).Nullable() + .WithColumn("active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_customerbillingprofiles_department ON customerbillingprofiles (departmentid, isdeleted);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_customerbillingprofiles_contact_live ON customerbillingprofiles (contactid) WHERE isdeleted = FALSE;"); + } + + if (!Schema.Table("ratecards").Exists()) + { + Create.Table("ratecards") + .WithColumn("ratecardid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("description").AsString(int.MaxValue).Nullable() + .WithColumn("isdefault").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratecards_department ON ratecards (departmentid, isdeleted);"); + } + + if (!Schema.Table("ratecarditems").Exists()) + { + Create.Table("ratecarditems") + .WithColumn("ratecarditemid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("ratecardid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("itemtype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("description").AsString(int.MaxValue).Nullable() + .WithColumn("rate").AsDecimal(18, 4).NotNullable().WithDefaultValue(0) + .WithColumn("unitlabel").AsString(50).Nullable() + .WithColumn("minimumminutes").AsInt32().Nullable() + .WithColumn("roundingminutes").AsInt32().Nullable() + .WithColumn("minimumcharge").AsDecimal(18, 4).Nullable() + .WithColumn("unittypefilter").AsString(100).Nullable() + .WithColumn("personnelroleidfilter").AsInt32().Nullable() + .WithColumn("taxable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratecarditems_ratecard ON ratecarditems (ratecardid, isdeleted, sortorder);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratecarditems_department ON ratecarditems (departmentid);"); + } + + if (!Schema.Table("departmentbillingidentities").Exists()) + { + Create.Table("departmentbillingidentities") + .WithColumn("departmentid").AsInt32().NotNullable().PrimaryKey() + .WithColumn("legalbusinessname").AsString(300).Nullable() + .WithColumn("remittoaddressid").AsInt32().Nullable() + .WithColumn("taxregistrationnumber").AsString(100).Nullable() + .WithColumn("secondarytaxregistrationnumber").AsString(100).Nullable() + .WithColumn("samuei").AsString(50).Nullable() + .WithColumn("cagecode").AsString(20).Nullable() + .WithColumn("workerscompaccountnumber").AsString(100).Nullable() + .WithColumn("invoicefootertext").AsString(int.MaxValue).Nullable() + .WithColumn("onlinepaymentsenabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("defaultpaymentconnectionid").AsString(36).Nullable() + .WithColumn("allowedpaymentmethodscsv").AsString(200).Nullable() + .WithColumn("paylinkexpirydays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("showpayonlineondocuments").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("updatedon").AsDateTime2().NotNullable() + .WithColumn("updatedbyuserid").AsString(128).Nullable(); + } + } + + public override void Down() + { + if (Schema.Table("departmentbillingidentities").Exists()) Delete.Table("departmentbillingidentities"); + if (Schema.Table("ratecarditems").Exists()) Delete.Table("ratecarditems"); + if (Schema.Table("ratecards").Exists()) Delete.Table("ratecards"); + if (Schema.Table("customerbillingprofiles").Exists()) Delete.Table("customerbillingprofiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs new file mode 100644 index 000000000..08c46d7f7 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs @@ -0,0 +1,123 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0210 (Workforce & Business Operations plan, Phase B, B1): invoices, line items, + /// payments and the invoice number sequence. Same number, lower-case names, guarded for safe retry. + /// + [Migration(210)] + public class M0210_AddInvoicesPg : Migration + { + public override void Up() + { + if (!Schema.Table("invoices").Exists()) + { + Create.Table("invoices") + .WithColumn("invoiceid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("invoicenumber").AsInt32().NotNullable() + .WithColumn("customerbillingprofileid").AsString(36).NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("issuedon").AsDateTime2().Nullable() + .WithColumn("dueon").AsDateTime2().Nullable() + .WithColumn("currency").AsString(3).NotNullable().WithDefaultValue("USD") + .WithColumn("subtotal").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("discountpercent").AsDecimal(9, 4).Nullable() + .WithColumn("discountamount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("taxamount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("total").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("amountpaid").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("taxcomponentsjson").AsString(int.MaxValue).Nullable() + .WithColumn("notes").AsString(int.MaxValue).Nullable() + .WithColumn("termstext").AsString(int.MaxValue).Nullable() + .WithColumn("senton").AsDateTime2().Nullable() + .WithColumn("senttoemail").AsString(500).Nullable() + .WithColumn("paidon").AsDateTime2().Nullable() + .WithColumn("voidedon").AsDateTime2().Nullable() + .WithColumn("voidreason").AsString(1000).Nullable() + .WithColumn("platformfeeamount").AsDecimal(18, 2).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_department_number ON invoices (departmentid, invoicenumber);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_department_status ON invoices (departmentid, status, isdeleted);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_department_contact ON invoices (departmentid, contactid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_department_due ON invoices (departmentid, dueon);"); + } + + if (!Schema.Table("invoicelineitems").Exists()) + { + Create.Table("invoicelineitems") + .WithColumn("invoicelineitemid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("invoiceid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("ratecarditemid").AsString(36).Nullable() + .WithColumn("description").AsString(1000).NotNullable() + .WithColumn("quantity").AsDecimal(18, 4).NotNullable().WithDefaultValue(1) + .WithColumn("unitrate").AsDecimal(18, 4).NotNullable().WithDefaultValue(0) + .WithColumn("amount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("taxable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicelineitems_invoice ON invoicelineitems (invoiceid, sortorder);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicelineitems_department_call ON invoicelineitems (departmentid, callid);"); + } + + if (!Schema.Table("invoicepayments").Exists()) + { + Create.Table("invoicepayments") + .WithColumn("invoicepaymentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("invoiceid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("amount").AsDecimal(18, 2).NotNullable() + .WithColumn("method").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("reference").AsString(200).Nullable() + .WithColumn("gatewaytransactionid").AsString(200).Nullable() + .WithColumn("paymentrequestid").AsString(36).Nullable() + .WithColumn("provider").AsInt32().Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("refundedamount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0) + .WithColumn("providerfeeamount").AsDecimal(18, 2).Nullable() + .WithColumn("netamount").AsDecimal(18, 2).Nullable() + .WithColumn("payeremail").AsString(500).Nullable() + .WithColumn("paymentmethodsummary").AsString(100).Nullable() + .WithColumn("receipturl").AsString(2048).Nullable() + .WithColumn("notes").AsString(int.MaxValue).Nullable() + .WithColumn("paidon").AsDateTime2().NotNullable() + .WithColumn("recordedbyuserid").AsString(128).Nullable() + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicepayments_invoice ON invoicepayments (invoiceid, paidon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicepayments_department_paid ON invoicepayments (departmentid, paidon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicepayments_gateway ON invoicepayments (provider, gatewaytransactionid);"); + } + + if (!Schema.Table("invoicenumbersequences").Exists()) + { + Create.Table("invoicenumbersequences") + .WithColumn("departmentid").AsInt32().NotNullable().PrimaryKey() + .WithColumn("nextinvoicenumber").AsInt32().NotNullable().WithDefaultValue(1); + } + } + + public override void Down() + { + if (Schema.Table("invoicenumbersequences").Exists()) Delete.Table("invoicenumbersequences"); + if (Schema.Table("invoicepayments").Exists()) Delete.Table("invoicepayments"); + if (Schema.Table("invoicelineitems").Exists()) Delete.Table("invoicelineitems"); + if (Schema.Table("invoices").Exists()) Delete.Table("invoices"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0211_SeedBusinessOperationsAddonAndFlagsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0211_SeedBusinessOperationsAddonAndFlagsPg.cs new file mode 100644 index 000000000..522850d3a --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0211_SeedBusinessOperationsAddonAndFlagsPg.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0211 (Workforce & Business Operations plan, Phase B): Business Operations add-on catalog + /// row, BusinessOperationsBillingAccounts, the Business.Operations and Invoicing.CustomerInvoicing flags, and the + /// prerequisite edge between them. Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(211)] + public class M0211_SeedBusinessOperationsAddonAndFlagsPg : Migration + { + private const string BusinessOperationsAddonId = "8c2f0d6e-5b1a-4f2e-9d3c-7a6b5e4d3c2b"; + + public override void Up() + { + Execute.Sql( + "INSERT INTO planaddons (planaddonid, addontype, cost, externalid, testexternalid) " + + "SELECT '" + BusinessOperationsAddonId + "', 4, 250, 'price_0UHEA6qJFDZJcnkVnj0ZaAFw', '' " + + "WHERE NOT EXISTS (SELECT 1 FROM planaddons WHERE planaddonid = '" + BusinessOperationsAddonId + "' OR addontype = 4);"); + + if (!Schema.Table("businessoperationsbillingaccounts").Exists()) + { + Create.Table("businessoperationsbillingaccounts") + .WithColumn("departmentid").AsInt32().NotNullable().PrimaryKey() + .WithColumn("provider").AsString(20).NotNullable() + .WithColumn("customerid").AsString(100).NotNullable() + .WithColumn("planaddonid").AsString(36).NotNullable() + .WithColumn("priceid").AsString(100).NotNullable() + .WithColumn("subscriptionid").AsString(100).Nullable() + .WithColumn("checkoutid").AsString(100).Nullable() + .WithColumn("checkouturl").AsString(2048).Nullable() + .WithColumn("checkoutexpireson").AsDateTime2().Nullable() + .WithColumn("checkoutattempt").AsString(36).Nullable() + .WithColumn("updatedon").AsDateTime2().NotNullable(); + } + + Execute.Sql( + "INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally, ispermanent) " + + "SELECT 'Business.Operations', 'Business Operations', 'Operator master toggle for the paid Business Operations add-on surfaces (customer invoicing, contractor billing, Cal OES MARS, workforce costing). Prerequisite of every paid Business Operations flag; the purchase itself is the Business Operations add-on. Seeded off.', 'Business', false, true " + + "WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Business.Operations');"); + + Execute.Sql( + "INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) " + + "SELECT 'Invoicing.CustomerInvoicing', 'Customer invoicing', 'Billing profiles, rate cards, invoices, payments and accounts-receivable aging (Workforce & Business Operations plan, Phase B). Requires Business.Operations and the Business Operations add-on. Seeded off.', 'Business', false " + + "WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Invoicing.CustomerInvoicing');"); + + Execute.Sql( + "INSERT INTO featureflagprerequisites (featureflagid, requiredfeatureflagid, requiredvalue) " + + "SELECT f.featureflagid, r.featureflagid, NULL FROM featureflags f CROSS JOIN featureflags r " + + "WHERE f.flagkey = 'Invoicing.CustomerInvoicing' AND r.flagkey = 'Business.Operations' " + + "AND NOT EXISTS (SELECT 1 FROM featureflagprerequisites p WHERE p.featureflagid = f.featureflagid AND p.requiredfeatureflagid = r.featureflagid);"); + } + + public override void Down() + { + Execute.Sql("DO $guard$ BEGIN IF to_regclass('businessoperationsbillingaccounts') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM businessoperationsbillingaccounts) THEN DROP TABLE businessoperationsbillingaccounts; END IF; END $guard$;"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/BusinessOperationsBillingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/BusinessOperationsBillingRepository.cs new file mode 100644 index 000000000..22c232365 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/BusinessOperationsBillingRepository.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Business Operations add-on billing accounts (M0211). Same shape and transaction rules as ReadinessProBillingRepository. + public sealed class BusinessOperationsBillingRepository : RmsRepositoryBase, IBusinessOperationsBillingRepository + { + public BusinessOperationsBillingRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } + + public Task LockDepartmentAsync(int departmentId) => LockRecordsDepartmentAsync(departmentId, default); + + public Task GetAsync(int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("BusinessOperationsBillingAccounts")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, default); + + public async Task FindAsync(string provider, string customerId, string subscriptionId, string checkoutId) + { + customerId = string.IsNullOrWhiteSpace(customerId) ? null : customerId; + subscriptionId = string.IsNullOrWhiteSpace(subscriptionId) ? null : subscriptionId; + checkoutId = string.IsNullOrWhiteSpace(checkoutId) ? null : checkoutId; + if (customerId == null && subscriptionId == null && checkoutId == null) return null; + var matches = (await QueryAsync( + $"SELECT * FROM {Tbl("BusinessOperationsBillingAccounts")} WHERE {Col("Provider")}={P}Provider AND ({Col("CustomerId")}={P}CustomerId OR {Col("SubscriptionId")}={P}SubscriptionId OR {Col("CheckoutId")}={P}CheckoutId)", + new { Provider = provider, CustomerId = customerId, SubscriptionId = subscriptionId, CheckoutId = checkoutId }, default)).ToList(); + if (matches.Count > 1) throw new InvalidOperationException("Business Operations billing ownership is ambiguous."); + return matches.SingleOrDefault(); + } + + public async Task SaveAsync(BusinessOperationsBillingAccount account) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Business Operations billing writes require the department transaction."); + var columns = typeof(BusinessOperationsBillingAccount).GetProperties().Select(p => p.Name).ToArray(); + var existing = await GetAsync(account.DepartmentId); + if (await ExecuteAsync(existing == null + ? $"INSERT INTO {Tbl("BusinessOperationsBillingAccounts")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl("BusinessOperationsBillingAccounts")} SET {string.Join(",", columns.Where(c => c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId", + account, default) != 1) + throw new InvalidOperationException("Business Operations billing account could not be saved."); + } + + public async Task> PaymentsAsync(int departmentId, string planAddonId) => + (await QueryAsync($"SELECT * FROM {Tbl("PaymentAddons")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PlanAddonId")}={P}PlanAddonId", new { DepartmentId = departmentId, PlanAddonId = planAddonId }, default)).ToList(); + + public async Task SavePaymentAsync(PaymentAddon payment, bool insert) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Business Operations billing writes require the department transaction."); + var columns = typeof(PaymentAddon).GetProperties().Where(p => p.CanWrite && !payment.IgnoredProperties.Contains(p.Name)).Select(p => p.Name).ToArray(); + if (await ExecuteAsync(insert + ? $"INSERT INTO {Tbl("PaymentAddons")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl("PaymentAddons")} SET {string.Join(",", columns.Where(c => c != "DepartmentId" && c != "PaymentAddonId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PaymentAddonId")}={P}PaymentAddonId AND {Col("PlanAddonId")}={P}PlanAddonId", + payment, default) != 1) + throw new InvalidOperationException("Business Operations billing payment could not be saved."); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs index 00d40e55f..38354a589 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -48,7 +48,7 @@ async Task Exists(string table) } if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {producerPredicate}", parameters, transaction, cancellationToken: ct)); if (await Exists("AuditLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=@DepartmentId AND {auditPredicate}", parameters, transaction, cancellationToken: ct)); - foreach (var table in new[] { "WorkOrderPartMovements", "WorkOrderVendorCharges", "WorkOrderOperationReceipts", "WorkOrderPolicies", "WorkOrderReportSnapshots", "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + foreach (var table in new[] { "InvoicePayments", "InvoiceLineItems", "Invoices", "InvoiceNumberSequences", "RateCardItems", "RateCards", "CustomerBillingProfiles", "DepartmentBillingIdentities", "BusinessOperationsBillingAccounts", "WorkOrderPartMovements", "WorkOrderVendorCharges", "WorkOrderOperationReceipts", "WorkOrderPolicies", "WorkOrderReportSnapshots", "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) if (await Exists(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index a25453547..7efedbdc8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -132,6 +132,7 @@ protected SqlConfiguration() { } public string MessageRecipientsTable { get; set; } public string SelectInboxMessagesByUserQuery { get; set; } public string SelectSentMessagesByUserQuery { get; set; } + public string SelectMessagesByDIdQuery { get; set; } public string SelectUnreadMessageCountQuery { get; set; } public string SelectMessageRecpByMessageUsQuery { get; set; } public string SelectMessageRecpsByUserQuery { get; set; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs new file mode 100644 index 000000000..e14683ad0 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Customer billing profiles (plan B3, registry M0209). Dapper over the shared dialect helpers, Phase A style. + public class CustomerBillingProfileRepository : RmsRepositoryBase, ICustomerBillingProfileRepository + { + public CustomerBillingProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string customerBillingProfileId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("CustomerBillingProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CustomerBillingProfileId")} = {P}Id AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, Id = customerBillingProfileId }); + } + + public Task GetByContactIdAsync(string contactId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("CustomerBillingProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", + new { DepartmentId = departmentId, ContactId = contactId }); + } + + public Task> GetByContactIdsAsync(int departmentId, IEnumerable contactIds) + { + var ids = InListValue(contactIds); + if (ids.Length == 0) + return Task.FromResult>(new List()); + + return QueryAsync( + $"SELECT * FROM {Tbl("CustomerBillingProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("ContactId", "ContactIds")} AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, ContactIds = ids }); + } + + public Task> GetAllForDepartmentAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("CustomerBillingProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", + new { DepartmentId = departmentId }); + } + + private static string False => IsPostgres ? "FALSE" : "0"; + } + + /// Rate cards (plan B3, registry M0209). + public class RateCardRepository : RmsRepositoryBase, IRateCardRepository + { + public RateCardRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string rateCardId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RateCards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RateCardId")} = {P}Id AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, Id = rateCardId }); + } + + public Task> GetAllForDepartmentAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RateCards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} ORDER BY {Col("IsDefault")} DESC, {Col("Name")}", + new { DepartmentId = departmentId }); + } + + public Task GetDefaultForDepartmentAsync(int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RateCards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDefault")} = {True} AND {Col("Active")} = {True} AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", + new { DepartmentId = departmentId }); + } + + public Task ClearDefaultAsync(int departmentId, string exceptRateCardId, CancellationToken cancellationToken = default) + { + return ExecuteAsync( + $"UPDATE {Tbl("RateCards")} SET {Col("IsDefault")} = {False} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RateCardId")} <> {P}Except AND {Col("IsDefault")} = {True}", + new { DepartmentId = departmentId, Except = exceptRateCardId ?? string.Empty }, cancellationToken); + } + + private static string False => IsPostgres ? "FALSE" : "0"; + private static string True => IsPostgres ? "TRUE" : "1"; + } + + /// Rate card items (plan B3, registry M0209). + public class RateCardItemRepository : RmsRepositoryBase, IRateCardItemRepository + { + public RateCardItemRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string rateCardItemId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RateCardItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RateCardItemId")} = {P}Id AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, Id = rateCardItemId }); + } + + public Task> GetByRateCardIdAsync(string rateCardId, int departmentId, bool includeInactive = false) + { + var active = includeInactive ? string.Empty : $" AND {Col("Active")} = {True}"; + return QueryAsync( + $"SELECT * FROM {Tbl("RateCardItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RateCardId")} = {P}RateCardId AND {Col("IsDeleted")} = {False}{active} ORDER BY {Col("SortOrder")}, {Col("Name")}", + new { DepartmentId = departmentId, RateCardId = rateCardId }); + } + + private static string False => IsPostgres ? "FALSE" : "0"; + private static string True => IsPostgres ? "TRUE" : "1"; + } + + /// Invoices (plan B3, registry M0210). + public class InvoiceRepository : RmsRepositoryBase, IInvoiceRepository + { + private static readonly int[] OpenStatuses = { (int)InvoiceStatus.Sent, (int)InvoiceStatus.PartiallyPaid, (int)InvoiceStatus.Overdue }; + + public InvoiceRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string invoiceId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoiceId")} = {P}Id AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, Id = invoiceId }); + } + + public Task GetByNumberAsync(int departmentId, int invoiceNumber) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoiceNumber")} = {P}Number AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, Number = invoiceNumber }); + } + + private string FilterSql(InvoiceListFilter filter, DynamicParameters parameters) + { + var sql = new StringBuilder($"{Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}"); + if (filter == null) + return sql.ToString(); + + var statuses = InListValue(filter.Statuses); + if (statuses.Length > 0) + { + sql.Append($" AND {InList("Status", "Statuses")}"); + parameters.Add("Statuses", statuses); + } + if (!string.IsNullOrWhiteSpace(filter.ContactId)) + { + sql.Append($" AND {Col("ContactId")} = {P}ContactId"); + parameters.Add("ContactId", filter.ContactId); + } + if (filter.IssuedFromUtc.HasValue) + { + sql.Append($" AND {Col("IssuedOn")} >= {P}IssuedFrom"); + parameters.Add("IssuedFrom", filter.IssuedFromUtc.Value); + } + if (filter.IssuedToUtc.HasValue) + { + sql.Append($" AND {Col("IssuedOn")} < {P}IssuedTo"); + parameters.Add("IssuedTo", filter.IssuedToUtc.Value); + } + return sql.ToString(); + } + + public Task> GetForDepartmentAsync(int departmentId, InvoiceListFilter filter) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + var where = FilterSql(filter, parameters); + var skip = Math.Max(0, filter?.Skip ?? 0); + var take = Math.Clamp(filter?.Take ?? 50, 1, 500); + parameters.Add("Skip", skip); + parameters.Add("Take", take); + + return QueryAsync( + $"SELECT * FROM {Tbl("Invoices")} WHERE {where} ORDER BY {Col("InvoiceNumber")} DESC {Paging()}", + parameters); + } + + public Task CountForDepartmentAsync(int departmentId, InvoiceListFilter filter) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + var where = FilterSql(filter, parameters); + return ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Invoices")} WHERE {where}", parameters); + } + + public Task> GetByContactIdAsync(string contactId, int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("InvoiceNumber")} DESC", + new { DepartmentId = departmentId, ContactId = contactId }); + } + + public Task> GetInvoicesByStatusAsync(int departmentId, int status) + { + return QueryAsync( + $"SELECT * FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Status")} = {P}Status AND {Col("IsDeleted")} = {False} ORDER BY {Col("InvoiceNumber")} DESC", + new { DepartmentId = departmentId, Status = status }); + } + + public Task> GetOverdueCandidatesAsync(DateTime asOfUtc, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("AsOf", asOfUtc); + parameters.Add("Statuses", new[] { (int)InvoiceStatus.Sent, (int)InvoiceStatus.PartiallyPaid }); + parameters.Add("Skip", 0); + parameters.Add("Take", Math.Clamp(take, 1, 5000)); + return QueryAsync( + $"SELECT * FROM {Tbl("Invoices")} WHERE {InList("Status", "Statuses")} AND {Col("DueOn")} IS NOT NULL AND {Col("DueOn")} < {P}AsOf AND {Col("IsDeleted")} = {False} ORDER BY {Col("DueOn")} {Paging()}", + parameters); + } + + public Task> GetAgingDataAsync(int departmentId) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Statuses", OpenStatuses); + return QueryAsync( + $"SELECT {Cols("InvoiceId", "InvoiceNumber", "ContactId", "Status", "DueOn", "Total", "AmountPaid")} FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("Status", "Statuses")} AND {Col("IsDeleted")} = {False} ORDER BY {Col("DueOn")}", + parameters); + } + + public async Task HasNonVoidInvoicesForContactAsync(string contactId, int departmentId) + { + var count = await ScalarAsync( + $"SELECT COUNT(*) FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("Status")} <> {P}Void AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, ContactId = contactId, Void = (int)InvoiceStatus.Void }); + return count > 0; + } + + private static string False => IsPostgres ? "FALSE" : "0"; + } + + /// Invoice line items (plan B3, registry M0210). + public class InvoiceLineItemRepository : RmsRepositoryBase, IInvoiceLineItemRepository + { + public InvoiceLineItemRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByInvoiceIdAsync(string invoiceId, int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("InvoiceLineItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoiceId")} = {P}InvoiceId ORDER BY {Col("SortOrder")}", + new { DepartmentId = departmentId, InvoiceId = invoiceId }); + } + + public Task> GetByCallIdAsync(int callId, int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("InvoiceLineItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CallId")} = {P}CallId ORDER BY {Col("SortOrder")}", + new { DepartmentId = departmentId, CallId = callId }); + } + + public Task DeleteByInvoiceIdAsync(string invoiceId, int departmentId, CancellationToken cancellationToken = default) + { + return ExecuteAsync( + $"DELETE FROM {Tbl("InvoiceLineItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoiceId")} = {P}InvoiceId", + new { DepartmentId = departmentId, InvoiceId = invoiceId }, cancellationToken); + } + + public Task DeleteByIdAsync(string invoiceLineItemId, int departmentId, CancellationToken cancellationToken = default) + { + return ExecuteAsync( + $"DELETE FROM {Tbl("InvoiceLineItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoiceLineItemId")} = {P}Id", + new { DepartmentId = departmentId, Id = invoiceLineItemId }, cancellationToken); + } + } + + /// Invoice payments (plan B3, registry M0210). + public class InvoicePaymentRepository : RmsRepositoryBase, IInvoicePaymentRepository + { + public InvoicePaymentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string invoicePaymentId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("InvoicePayments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoicePaymentId")} = {P}Id", + new { DepartmentId = departmentId, Id = invoicePaymentId }); + } + + public Task> GetByInvoiceIdAsync(string invoiceId, int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("InvoicePayments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("InvoiceId")} = {P}InvoiceId ORDER BY {Col("PaidOn")}, {Col("AddedOn")}", + new { DepartmentId = departmentId, InvoiceId = invoiceId }); + } + + public Task GetByGatewayTransactionIdAsync(int provider, string gatewayTransactionId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("InvoicePayments")} WHERE {Col("Provider")} = {P}Provider AND {Col("GatewayTransactionId")} = {P}Gateway", + new { Provider = provider, Gateway = gatewayTransactionId }); + } + } + + /// Per-department invoice numbering (plan decision 7): one atomic upsert-and-increment per dialect. + public class InvoiceNumberSequenceRepository : RmsRepositoryBase, IInvoiceNumberSequenceRepository + { + public InvoiceNumberSequenceRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetNextNumberAsync(int departmentId, CancellationToken cancellationToken = default) + { + // The row stores the NEXT number to hand out. Both statements insert the row on first use (handing out 1) + // and otherwise advance it, returning the number that was handed out, in a single atomic statement. + string sql; + if (IsPostgres) + { + sql = $"INSERT INTO {Tbl("InvoiceNumberSequences")} ({Col("DepartmentId")}, {Col("NextInvoiceNumber")}) VALUES ({P}DepartmentId, 2) " + + $"ON CONFLICT ({Col("DepartmentId")}) DO UPDATE SET {Col("NextInvoiceNumber")} = {Tbl("InvoiceNumberSequences")}.{Col("NextInvoiceNumber")} + 1 " + + $"RETURNING {Col("NextInvoiceNumber")} - 1"; + } + else + { + sql = $"MERGE {Tbl("InvoiceNumberSequences")} WITH (HOLDLOCK) AS t USING (SELECT {P}DepartmentId AS DepartmentId) AS s ON t.[DepartmentId] = s.DepartmentId " + + "WHEN MATCHED THEN UPDATE SET [NextInvoiceNumber] = t.[NextInvoiceNumber] + 1 " + + "WHEN NOT MATCHED THEN INSERT ([DepartmentId], [NextInvoiceNumber]) VALUES (s.DepartmentId, 2) " + + "OUTPUT inserted.[NextInvoiceNumber] - 1;"; + } + + return ScalarAsync(sql, new { DepartmentId = departmentId }, cancellationToken); + } + } + + /// The department's billing identity (plan B1, registry M0209): explicit upsert keyed by DepartmentId. + public class DepartmentBillingIdentityRepository : RmsRepositoryBase, IDepartmentBillingIdentityRepository + { + public DepartmentBillingIdentityRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByDepartmentIdAsync(int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("DepartmentBillingIdentities")} WHERE {Col("DepartmentId")} = {P}DepartmentId", + new { DepartmentId = departmentId }); + } + + public async Task UpsertAsync(DepartmentBillingIdentity identity, CancellationToken cancellationToken = default) + { + if (identity == null) + throw new ArgumentNullException(nameof(identity)); + + var columns = new[] + { + "LegalBusinessName", "RemitToAddressId", "TaxRegistrationNumber", "SecondaryTaxRegistrationNumber", "SamUei", "CageCode", + "WorkersCompAccountNumber", "InvoiceFooterText", "OnlinePaymentsEnabled", "DefaultPaymentConnectionId", "AllowedPaymentMethodsCsv", + "PayLinkExpiryDays", "ShowPayOnlineOnDocuments", "UpdatedOn", "UpdatedByUserId" + }; + var setList = string.Join(", ", columns.Select(c => $"{Col(c)} = {P}{c}")); + + var updated = await ExecuteAsync( + $"UPDATE {Tbl("DepartmentBillingIdentities")} SET {setList} WHERE {Col("DepartmentId")} = {P}DepartmentId", + identity, cancellationToken); + + if (updated == 0) + { + var all = new[] { "DepartmentId" }.Concat(columns).ToArray(); + await ExecuteAsync( + $"INSERT INTO {Tbl("DepartmentBillingIdentities")} ({string.Join(", ", all.Select(Col))}) VALUES ({string.Join(", ", all.Select(c => P + c))})", + identity, cancellationToken); + } + + return await GetByDepartmentIdAsync(identity.DepartmentId); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs index c84364470..9c4515cc8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs @@ -261,6 +261,54 @@ public async Task> GetSentMessagesByUserIdAsync(string user } } + public async Task> GetMessagesByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + var messageDictionary = new Dictionary(); + var result = await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction, + map: MessageRecipientsMapping(messageDictionary), + splitOn: "MessageRecipientId"); + + if (messageDictionary.Count > 0) + return messageDictionary.Select(y => y.Value); + + return result; + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + return null; + } + } + public async Task> GetMessagesByUserSendRecIdAsync(string userId) { try diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index c80eb010e..badae045c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -16,6 +16,15 @@ public class ApiDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 0a0733024..bafdb9d00 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -16,6 +16,7 @@ public class DataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -229,6 +230,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase B (registry M0209–M0210): customer invoicing. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Indoor Maps Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index b6eaba43a..9fe18ce5f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -16,6 +16,15 @@ public class NonWebDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index dad6b0d0b..4d4460276 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -16,6 +16,15 @@ public class TestingDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectMessagesByDIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectMessagesByDIdQuery.cs new file mode 100644 index 000000000..8d6a2c58e --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectMessagesByDIdQuery.cs @@ -0,0 +1,46 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Messages +{ + public class SelectMessagesByDIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectMessagesByDIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectMessagesByDIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%" + }, + new string[] { + "DepartmentId", + }, + new string[] { + "%MESSAGESTABLE%", + "%MESSAGERECIPIENTSTABLE%" + }, + new string[] { + _sqlConfiguration.MessagesTable, + _sqlConfiguration.MessageRecipientsTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs index 3584503a2..071325cb4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs @@ -8,6 +8,7 @@ using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Search; +using Resgrid.Framework; using Resgrid.Repositories.DataRepository.Configs; namespace Resgrid.Repositories.DataRepository @@ -18,6 +19,16 @@ public class SearchProjectionsRepository : RmsRepositoryBase, public SearchProjectionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + /// PostgreSQL 23505 or SQL Server 2601/2627: the only failures the insert-then-update race is allowed to absorb. + internal static bool IsUniqueViolation(Exception ex) + { + if (ex is Npgsql.PostgresException postgres) + return postgres.SqlState == "23505"; + if (ex is Microsoft.Data.SqlClient.SqlException sql) + return sql.Number == 2601 || sql.Number == 2627; + return false; + } + public Task GetAsync(int departmentId, string entityType, string entityId) { return QueryFirstOrDefaultAsync( @@ -44,9 +55,11 @@ public async Task UpsertAsync(SearchProjection projection, Can { return await InsertAsync(projection, cancellationToken, true); } - catch (Exception) + catch (Exception ex) when (IsUniqueViolation(ex)) { // Two writers raced on the unique (DepartmentId, EntityType, EntityId) index; fall through to update. + // Only that conflict is a race: a connection or permission failure propagates to the caller's guard. + Logging.LogException(ex, $"Search projection insert conflicted for {projection.EntityType} {projection.EntityId} in department {projection.DepartmentId}; retrying as an update."); existing = await GetAsync(projection.DepartmentId, projection.EntityType, projection.EntityId); if (existing == null) throw; } @@ -188,9 +201,11 @@ await ExecuteAsync( new { IndexName = indexName, Owner = owner, Until = until, Now = now }, cancellationToken); return true; } - catch (Exception) + catch (Exception ex) when (SearchProjectionsRepository.IsUniqueViolation(ex)) { - // Lost the insert race; the other writer holds it. + // Lost the insert race; the other writer holds it. Any other database failure propagates so the + // publish is reported as failed rather than as "lease held elsewhere". + Logging.LogException(ex, $"Search index lease insert for '{indexName}' lost the race to another writer."); return false; } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index d3f1edbd6..15794f87a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -387,6 +387,11 @@ SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m FROM %SCHEMA%.%MESSAGESTABLE% m LEFT JOIN %SCHEMA%.%MESSAGERECIPIENTSTABLE% mr ON mr.MessageId = m.MessageId WHERE m.SendingUserId = %USERID% AND m.IsDeleted = false"; + SelectMessagesByDIdQuery = @" + SELECT m.*, mr.* + FROM %SCHEMA%.%MESSAGESTABLE% m + LEFT JOIN %SCHEMA%.%MESSAGERECIPIENTSTABLE% mr ON mr.MessageId = m.MessageId + WHERE m.DepartmentId = %DID% AND m.IsDeleted = false"; UpdateRecievedMessagesAsDeletedQuery = @" UPDATE %SCHEMA%.%TABLENAME% SET IsDeleted = true diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index aab4694d7..8b1adb8d1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -385,6 +385,11 @@ SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m FROM %SCHEMA%.%MESSAGESTABLE% m LEFT JOIN %SCHEMA%.%MESSAGERECIPIENTSTABLE% mr ON mr.[MessageId] = m.[MessageId] WHERE m.[SendingUserId] = %USERID% AND m.[IsDeleted] = 0"; + SelectMessagesByDIdQuery = @" + SELECT m.*, mr.* + FROM %SCHEMA%.%MESSAGESTABLE% m + LEFT JOIN %SCHEMA%.%MESSAGERECIPIENTSTABLE% mr ON mr.[MessageId] = m.[MessageId] + WHERE m.[DepartmentId] = %DID% AND m.[IsDeleted] = 0"; UpdateRecievedMessagesAsDeletedQuery = @" UPDATE %SCHEMA%.%TABLENAME% SET [IsDeleted] = 1 diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 48a29adb7..999054f5a 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -85,6 +85,14 @@ "ChecklistCompleted": 67, "ChecklistFailed": 68, "WorkOrderCreated": 70, + "InvoiceCreated": 52, + "InvoiceSent": 53, + "InvoicePaymentRecorded": 54, + "InvoicePaid": 55, + "InvoiceOverdue": 56, + "InvoiceVoided": 57, + "InvoicePaymentRefunded": 94, + "InvoicePaymentDisputed": 95, "WorkOrderStatusChanged": 71, "WorkOrderAssigned": 72, "InventoryTransferCompleted": 58, diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index ddb10ff64..d27bdccfc 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -37,11 +37,14 @@ public void Permission_types_50_to_67_are_the_registry_names() // 68 is Unified Search's ManageSearchIndex; RMS-5 took 69 from the pool released on 2026-08-27. Enum.IsDefined(typeof(PermissionTypes), 68).Should().BeFalse("68 is reserved for Unified Search's ManageSearchIndex, which is not authored yet"); - // Inventory consumed its 47-49 reservation in P1-M1/M2; 40-46 remain unauthored. + // Inventory consumed its 47-49 reservation in P1-M1/M2; Contacts/Billing Phase B took 40-41 on 2026-09-18; + // 42-46 remain unauthored (Certifications 42-44, buffer 45-46). ((int)PermissionTypes.TransferInventory).Should().Be(47); ((int)PermissionTypes.IssueInventory).Should().Be(48); ((int)PermissionTypes.ManageControlledSubstances).Should().Be(49); - foreach (var value in Enumerable.Range(40, 7)) + ((int)PermissionTypes.ManageInvoicing).Should().Be(40); + ((int)PermissionTypes.ViewInvoicing).Should().Be(41); + foreach (var value in Enumerable.Range(42, 5)) Enum.IsDefined(typeof(PermissionTypes), value).Should().BeFalse($"PermissionTypes {value} is reserved for another plan"); } @@ -107,7 +110,12 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.InventoryPurchaseOrderReceived).Should().Be(65); ((int)WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(66); ((int)WorkflowTriggerEventType.InventoryReturnOverdue).Should().Be(166); - foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(58, 16))) + // Contacts/Billing Phase B authored 52-57 and, from the buffer, 94-95 on 2026-09-18 (registry). + ((int)WorkflowTriggerEventType.InvoiceCreated).Should().Be(52); + ((int)WorkflowTriggerEventType.InvoiceVoided).Should().Be(57); + ((int)WorkflowTriggerEventType.InvoicePaymentRefunded).Should().Be(94); + ((int)WorkflowTriggerEventType.InvoicePaymentDisputed).Should().Be(95); + foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(52, 6)).Except(Enumerable.Range(58, 16)).Except(new[] { 94, 95 })) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs index 49934825c..9453f01cd 100644 --- a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs +++ b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs @@ -60,6 +60,16 @@ public async Task Department_filter_is_always_injected() two.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "11" }); } + [Test] + public async Task An_unbounded_skip_yields_an_empty_page_instead_of_an_overflowed_window() + { + var result = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "structure fire", ViewerUserId = "u1", Skip = int.MaxValue, Take = 20 }); + + result.Available.Should().BeTrue(); + result.Hits.Should().BeEmpty(); + result.Total.Should().Be(1); + } + [Test] public async Task Messages_are_visible_only_to_sender_or_recipient() { diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs index 424a76fc5..1bd78be14 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs @@ -23,6 +23,9 @@ public class UnifiedSearchServiceTests private Mock _auth; private Mock _states; private Mock _recordsSearch; + private Mock _recordsAuth; + private Mock _records; + private Mock _cutover; private UnifiedSearchService _service; private bool _flagOn; private GlobalSearchQuery _lastQuery; @@ -54,9 +57,27 @@ public void SetUp() _states = new Mock(); _recordsSearch = new Mock(); _recordsSearch.SetupGet(r => r.IsAvailable).Returns(false); + _recordsAuth = new Mock(); + _records = new Mock(); + _cutover = new Mock(); _service = new UnifiedSearchService(_global.Object, _actions.Object, _flags.Object, _auth.Object, _states.Object, _recordsSearch.Object, - new Mock().Object, new Mock().Object, new Mock().Object); + _recordsAuth.Object, _records.Object, _cutover.Object); + } + + /// An activated Records module whose index answers with the given hits; every record is viewable and loadable. + private void RecordsAnswer(params RecordsSearchHit[] hits) + { + var recordSource = ((int)RmsSearchSourceType.Record).ToString(); + _recordsSearch.SetupGet(r => r.IsAvailable).Returns(true); + _recordsSearch.Setup(r => r.SearchAsync(7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new RecordsSearchResult { Hits = hits.ToList(), Total = hits.Length }); + _cutover.Setup(c => c.GetModuleStateAsync(7, It.IsAny())).ReturnsAsync(new RecordsModuleState { DepartmentId = 7, FlagEnabled = true, Activated = true }); + _recordsAuth.Setup(a => a.IsActiveMemberAsync("u1", 7)).ReturnsAsync(true); + _recordsAuth.Setup(a => a.IsGroupScopedAsync(7)).ReturnsAsync(false); + _recordsAuth.Setup(a => a.CanUserViewRecordAsync("u1", It.IsAny(), 7)).ReturnsAsync(true); + _records.Setup(r => r.GetProjectionsByIdsAsync(7, It.IsAny>())) + .ReturnsAsync((int _, IEnumerable ids) => ids.Select(id => new RmsRecordSearchProjection { RmsRecordSearchProjectionId = id, DepartmentId = 7, SourceType = int.Parse(recordSource), SourceId = id, RecordNumber = "R-" + id }).ToList()); } private static SearchPrincipal Principal(params string[] claims) => new SearchPrincipal @@ -109,6 +130,33 @@ public async Task Claims_decide_which_families_reach_the_index() none.Hits.Should().BeEmpty(); } + [Test] + public async Task Records_follow_the_index_hits_on_every_page() + { + var recordSource = ((int)RmsSearchSourceType.Record).ToString(); + RecordsAnswer(new RecordsSearchHit { SourceType = recordSource, SourceId = "r1", Score = 1f }); + + var first = await _service.SearchAsync(new UnifiedSearchRequest { Text = "one", Skip = 0, Take = 2 }, Principal("Call:View", "Record:View")); + first.Hits.Select(h => h.EntityId).Should().Equal("1", "2"); + + var second = await _service.SearchAsync(new UnifiedSearchRequest { Text = "one", Skip = 2, Take = 2 }, Principal("Call:View", "Record:View")); + second.Hits.Select(h => h.EntityType).Should().Equal(SearchEntityTypes.Record); + second.Hits.Single().EntityId.Should().Be("r1"); + second.Total.Should().Be(3); + } + + [Test] + public async Task A_non_record_document_in_the_records_index_is_not_a_dropped_hit() + { + var recordSource = ((int)RmsSearchSourceType.Record).ToString(); + var legacySource = ((int)RmsSearchSourceType.LegacyLog).ToString(); + RecordsAnswer(new RecordsSearchHit { SourceType = recordSource, SourceId = "r1", Score = 2f }, new RecordsSearchHit { SourceType = legacySource, SourceId = "log-1", Score = 1f }); + + var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "one", Take = 10 }, Principal("Call:View", "Record:View")); + result.Hits.Where(h => h.EntityType == SearchEntityTypes.Record).Select(h => h.EntityId).Should().Equal("r1"); + result.Total.Should().NotBeNull("only record-source hits are judged for authorization"); + } + [Test] public async Task Index_unavailable_degrades_and_activates_the_department_lazily() { diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs index d9e6aaaae..642907107 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs @@ -81,10 +81,14 @@ public async Task Schedule_page_encodes_untrusted_names_timezones_and_all_route_ { const string attack = "\">(); - checklists.Setup(s => s.SchedulesAsync(It.IsAny(), attack, 1, false)).ReturnsAsync(new List + // The controller asks for one row past the page (includeNext) and renders Next only when it comes back, so + // 51 rows keep every pager link on the page; the extra rows are benign and add no untrusted links. + var rows = new List { new ChecklistScheduleView { Schedule = new ChecklistSchedule { Id = attack, TimeZoneId = attack, Frequency = 2 }, Content = new ChecklistScheduleContent { Name = attack } } - }); + }; + rows.AddRange(Enumerable.Range(1, 50).Select(i => new ChecklistScheduleView { Schedule = new ChecklistSchedule { Id = "benign-" + i, TimeZoneId = "UTC", Frequency = 2 }, Content = new ChecklistScheduleContent { Name = "Benign " + i } })); + checklists.Setup(s => s.SchedulesAsync(It.IsAny(), attack, 1, true)).ReturnsAsync(rows); _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(canEdit); var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; diff --git a/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceHealthTests.cs b/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceHealthTests.cs new file mode 100644 index 000000000..b980d6084 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceHealthTests.cs @@ -0,0 +1,199 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services.Invoicing; +using PaymentConnectConfig = Resgrid.Config.PaymentConnectConfig; + +namespace Resgrid.Tests.Services +{ + /// Plan B2.5a / B2.7 "Health": the Stripe Connect webhook health read behind the v4 Health endpoint. + [TestFixture] + public class InvoicePaymentsServiceHealthTests + { + private bool _enabled; + private string _secretKey; + private string _webhookSecret; + private string _publicBaseUrl; + private bool _probeEnabled; + private bool _liveMode; + + private Mock _toggles; + private Mock _probe; + + [SetUp] + public void SetUp() + { + _enabled = PaymentConnectConfig.Enabled; + _secretKey = PaymentConnectConfig.StripeSecretKey; + _webhookSecret = PaymentConnectConfig.StripeConnectWebhookSecret; + _publicBaseUrl = PaymentConnectConfig.PublicBaseUrl; + _probeEnabled = PaymentConnectConfig.WebhookEndpointProbeEnabled; + _liveMode = PaymentConnectConfig.StripeLiveMode; + + PaymentConnectConfig.Enabled = true; + PaymentConnectConfig.StripeSecretKey = "sk_test_platform"; + PaymentConnectConfig.StripeConnectWebhookSecret = "whsec_connect"; + PaymentConnectConfig.PublicBaseUrl = "https://api.example.test/"; + PaymentConnectConfig.WebhookEndpointProbeEnabled = true; + PaymentConnectConfig.StripeLiveMode = false; + + _toggles = new Mock(); + _probe = new Mock(); + InvoicePaymentsService.ResetEndpointProbeCache(); + } + + [TearDown] + public void TearDown() + { + PaymentConnectConfig.Enabled = _enabled; + PaymentConnectConfig.StripeSecretKey = _secretKey; + PaymentConnectConfig.StripeConnectWebhookSecret = _webhookSecret; + PaymentConnectConfig.PublicBaseUrl = _publicBaseUrl; + PaymentConnectConfig.WebhookEndpointProbeEnabled = _probeEnabled; + PaymentConnectConfig.StripeLiveMode = _liveMode; + InvoicePaymentsService.ResetEndpointProbeCache(); + } + + private InvoicePaymentsService Build() => new InvoicePaymentsService(_toggles.Object, _probe.Object); + + private void ClusterFlag(bool? enabledGlobally, bool archived = false) + { + var flag = enabledGlobally.HasValue + ? new FeatureFlag { FlagKey = FeatureFlagKeys.PaymentsStripeConnect, IsEnabledGlobally = enabledGlobally.Value, IsArchived = archived } + : null; + _toggles.Setup(t => t.GetFlagByKeyAsync(FeatureFlagKeys.PaymentsStripeConnect, It.IsAny())).ReturnsAsync(flag); + } + + [Test] + public async Task Config_off_reports_disabled_and_healthy_without_touching_the_flag_or_stripe() + { + PaymentConnectConfig.Enabled = false; + ClusterFlag(true); + + var health = await Build().GetWebhookHealthAsync(); + + health.Enabled.Should().BeFalse(); + health.Healthy.Should().BeTrue(); + health.WebhookConfigured.Should().BeFalse(); + health.EndpointRegistered.Should().BeNull(); + _toggles.Verify(t => t.GetFlagByKeyAsync(It.IsAny(), It.IsAny()), Times.Never); + _probe.Verify(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + } + + [Test] + public async Task Cluster_flag_missing_or_off_reports_disabled_and_healthy() + { + ClusterFlag(null); + (await Build().GetWebhookHealthAsync()).Enabled.Should().BeFalse(); + + ClusterFlag(false); + var health = await Build().GetWebhookHealthAsync(); + health.Enabled.Should().BeFalse(); + health.Healthy.Should().BeTrue(); + + ClusterFlag(true, archived: true); + (await Build().GetWebhookHealthAsync()).Enabled.Should().BeFalse(); + + _probe.Verify(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + } + + [Test] + public async Task Enabled_without_a_webhook_secret_is_not_configured_and_unhealthy() + { + ClusterFlag(true); + PaymentConnectConfig.StripeConnectWebhookSecret = ""; + + var health = await Build().GetWebhookHealthAsync(); + + health.Enabled.Should().BeTrue(); + health.WebhookConfigured.Should().BeFalse(); + health.EndpointRegistered.Should().BeNull(); + health.Healthy.Should().BeFalse(); + _probe.Verify(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + } + + [Test] + public async Task Enabled_and_configured_asks_stripe_with_the_cluster_webhook_url_and_required_events() + { + ClusterFlag(true); + _probe.Setup(p => p.IsEndpointRegisteredAsync("https://api.example.test/api/PaymentWebhooks/stripe", false, InvoicePaymentsService.StripeConnectRequiredEvents)) + .ReturnsAsync(true); + + var health = await Build().GetWebhookHealthAsync(); + + health.Enabled.Should().BeTrue(); + health.WebhookConfigured.Should().BeTrue(); + health.EndpointRegistered.Should().BeTrue(); + health.Healthy.Should().BeTrue(); + } + + [Test] + public async Task Missing_endpoint_is_unhealthy_but_an_unknown_probe_result_is_not() + { + ClusterFlag(true); + _probe.Setup(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(false); + (await Build().GetWebhookHealthAsync()).Healthy.Should().BeFalse(); + + InvoicePaymentsService.ResetEndpointProbeCache(); + _probe.Setup(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync((bool?)null); + var unknown = await Build().GetWebhookHealthAsync(); + unknown.EndpointRegistered.Should().BeNull(); + unknown.Healthy.Should().BeTrue(); + } + + [Test] + public async Task Probe_failure_degrades_to_unknown_and_the_call_still_succeeds() + { + ClusterFlag(true); + _probe.Setup(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .ThrowsAsync(new System.Net.Http.HttpRequestException("stripe unreachable")); + + var health = await Build().GetWebhookHealthAsync(); + + health.Enabled.Should().BeTrue(); + health.WebhookConfigured.Should().BeTrue(); + health.EndpointRegistered.Should().BeNull(); + health.Healthy.Should().BeTrue(); + } + + [Test] + public async Task Probe_result_is_cached_per_process_for_fifteen_minutes() + { + ClusterFlag(true); + _probe.Setup(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + await Build().GetWebhookHealthAsync(); + await Build().GetWebhookHealthAsync(); + + _probe.Verify(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Once); + } + + [Test] + public async Task Probe_is_skipped_when_disabled_by_configuration() + { + ClusterFlag(true); + PaymentConnectConfig.WebhookEndpointProbeEnabled = false; + + var health = await Build().GetWebhookHealthAsync(); + + health.EndpointRegistered.Should().BeNull(); + health.Healthy.Should().BeTrue(); + _probe.Verify(p => p.IsEndpointRegisteredAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + } + + [Test] + public void Webhook_url_is_built_from_the_public_base_url_only() + { + PaymentConnectConfig.PublicBaseUrl = "https://api.example.test///"; + PaymentConnectConfig.GetWebhookUrl().Should().Be("https://api.example.test/api/PaymentWebhooks/stripe"); + + PaymentConnectConfig.PublicBaseUrl = ""; + PaymentConnectConfig.GetWebhookUrl().Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InvoicingLocalizationTests.cs b/Tests/Resgrid.Tests/Services/InvoicingLocalizationTests.cs new file mode 100644 index 000000000..ceed871cb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InvoicingLocalizationTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Localization; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// + /// Twin of for the invoicing module (Workforce & Business Operations plan, + /// Phase B). Every resource key a view, the controller or the service's error codes reference must exist, and every + /// supported culture must carry a complete, non-English translation. + /// + [TestFixture] + public class InvoicingLocalizationTests + { + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + + [Test] + public void Invoicing_views_controllers_and_service_errors_resolve_real_resource_keys() + { + var root = RepositoryRoot(); + var web = Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User"); + var files = Directory.GetFiles(Path.Combine(web, "Views", "Invoicing"), "*.cshtml") + .Concat(Directory.GetFiles(Path.Combine(web, "Views", "BusinessOperationsBilling"), "*.cshtml")) + .Concat(new[] + { + Path.Combine(web, "Controllers", "InvoicingController.cs"), + Path.Combine(web, "Controllers", "BusinessOperationsBillingController.cs"), + Path.Combine(web, "Views", "Contacts", "View.cshtml"), + Path.Combine(web, "Views", "Department", "ModuleSettings.cshtml"), + Path.Combine(web, "Views", "Shared", "_Navigation.cshtml"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "InvoicingService.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "InvoicingService.Delivery.cs") + }); + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + var source = File.ReadAllText(file); + var pattern = file.EndsWith("View.cshtml") || file.EndsWith("ModuleSettings.cshtml") || file.EndsWith("_Navigation.cshtml") + ? """invoicingLocalizer\["([^"]+)"\]""" + : """(?().Skip(1).First(g => g.Success).Value); + } + + // Enum-derived keys the views build by concatenation. + foreach (var status in Enum.GetNames()) keys.Add("Status" + status); + foreach (var method in Enum.GetNames()) keys.Add("Method" + method); + foreach (var state in Enum.GetNames()) keys.Add("PaymentStatus" + state); + foreach (var type in Enum.GetNames()) keys.Add("ItemType" + type); + + var resources = Read(Path.Combine(ResourceDirectory(), "Invoicing.resx")); + keys.Except(resources.Keys).Should().BeEmpty("user interfaces and errors must not show untranslated resource identifiers"); + } + + // These values are the same in both languages; they are reviewed, not untranslated. + private static readonly Dictionary SharedSpellings = new Dictionary + { + ["de"] = new[] { "Status", "Name", "Minimum", "Links", "ItemTypeMaterial" }, + ["es"] = new[] { "Total", "SubTotal", "ItemTypeMaterial" }, + ["fr"] = new[] { "Total", "Description", "Notes", "Actions", "ItemType", "Taxable", "Address", "Minimum" }, + ["it"] = new[] { "MethodOnline", "Minimum" }, + ["pl"] = new[] { "Status", "MethodOnline", "Minimum" }, + ["sv"] = new[] { "Status", "Default", "MethodCheck", "MethodOnline", "ItemTypeMaterial", "Links", "Minimum" }, + }; + + private static readonly string[] SharedEverywhere = { "BusinessOperations", "SamUei" }; + + [TestCaseSource(nameof(Cultures))] + public void Supported_culture_has_complete_compiled_translations_without_English_placeholders(string culture) + { + var baseline = Read(Path.Combine(ResourceDirectory(), "Invoicing.resx")); + var file = Path.Combine(ResourceDirectory(), "Invoicing." + culture + ".resx"); + File.Exists(file).Should().BeTrue("every supported language needs its own dictionary"); + var translated = Read(file); + translated.Keys.Should().BeEquivalentTo(baseline.Keys); + var manager = new ResourceManager("Resgrid.Localization.Areas.User.Invoicing.Invoicing", typeof(SupportedLocales).Assembly); + var compiled = manager.GetResourceSet(CultureInfo.GetCultureInfo(culture), true, false); + compiled.Should().NotBeNull("the language resource must be included in the built assembly"); + var allowed = (SharedSpellings.TryGetValue(culture, out var entries) ? entries : Array.Empty()).Concat(SharedEverywhere).ToArray(); + foreach (var entry in translated) + { + entry.Value.Should().NotBeNullOrWhiteSpace(culture + ": " + entry.Key); + compiled.GetString(entry.Key).Should().Be(entry.Value, culture + ": " + entry.Key); + Regex.Matches(entry.Value, @"\{\d+\}").Select(m => m.Value).Should().BeEquivalentTo(Regex.Matches(baseline[entry.Key], @"\{\d+\}").Select(m => m.Value), "format arguments must survive translation: " + entry.Key); + if (culture != "en" && !allowed.Contains(entry.Key)) entry.Value.Should().NotBe(baseline[entry.Key], culture + " must translate " + entry.Key); + } + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root unavailable."); + } + + private static string ResourceDirectory() => Path.Combine(RepositoryRoot(), "Core", "Resgrid.Localization", "Areas", "User", "Invoicing"); + + private static Dictionary Read(string file) + { + var entries = XDocument.Load(file).Root.Elements("data").ToList(); + entries.Select(e => (string)e.Attribute("name")).Should().OnlyHaveUniqueItems(); + return entries.ToDictionary(e => (string)e.Attribute("name"), e => (string)e.Element("value"), StringComparer.Ordinal); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs new file mode 100644 index 000000000..d2b8e6d5b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// Workforce & Business Operations plan Phase B (B4 / Verification): invoice math, lifecycle and payment choke point. + [TestFixture] + public class InvoicingServiceTests + { + private Mock _profiles; + private Mock _rateCards; + private Mock _rateCardItems; + private Mock _invoices; + private Mock _lineItems; + private Mock _payments; + private Mock _sequence; + private Mock _identities; + private Mock _contacts; + private Mock _calls; + private Mock _units; + private Mock _outbox; + private Mock _events; + private Mock _pdf; + private Mock _email; + private Mock _departments; + private Mock _addresses; + private List _published; + private List _audits; + + [SetUp] + public void SetUp() + { + _profiles = new Mock(); + _rateCards = new Mock(); + _rateCardItems = new Mock(); + _invoices = new Mock(); + _lineItems = new Mock(); + _payments = new Mock(); + _sequence = new Mock(); + _identities = new Mock(); + _contacts = new Mock(); + _calls = new Mock(); + _units = new Mock(); + _outbox = new Mock(); + _events = new Mock(); + _pdf = new Mock(); + _email = new Mock(); + _departments = new Mock(); + _addresses = new Mock(); + _departments.Setup(d => d.GetDepartmentByIdAsync(7, It.IsAny())).ReturnsAsync(new Department { DepartmentId = 7, Name = "Test County Fire" }); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(html => System.Text.Encoding.UTF8.GetBytes(html)); + _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + _published = new List(); + _audits = new List(); + + _outbox.Setup(o => o.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, e, ___) => _published.Add(e)) + .ReturnsAsync(new DomainEventOutboxEntry()); + _events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + + // Repositories echo the saved entity and assign a GUID to a null id, as RepositoryBase does. + _invoices.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Invoice i, CancellationToken _, bool __) => { i.InvoiceId ??= Guid.NewGuid().ToString(); return i; }); + _payments.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InvoicePayment p, CancellationToken _, bool __) => { p.InvoicePaymentId ??= Guid.NewGuid().ToString(); return p; }); + _lineItems.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InvoiceLineItem l, CancellationToken _, bool __) => { l.InvoiceLineItemId ??= Guid.NewGuid().ToString(); return l; }); + _contacts.Setup(c => c.GetContactByIdAsync(It.IsAny())).ReturnsAsync(new Contact { ContactId = "contact-1", DepartmentId = 7, CompanyName = "Acme Logistics" }); + } + + private InvoicingService Build() => new InvoicingService(_profiles.Object, _rateCards.Object, _rateCardItems.Object, _invoices.Object, + _lineItems.Object, _payments.Object, _sequence.Object, _identities.Object, _contacts.Object, _calls.Object, _units.Object, _outbox.Object, _events.Object, + _pdf.Object, _email.Object, _departments.Object, _addresses.Object); + + private static CustomerBillingProfile Profile(decimal? taxRate = null, string components = null, decimal? discount = null, bool taxExempt = false) => + new CustomerBillingProfile { CustomerBillingProfileId = "profile-1", DepartmentId = 7, ContactId = "contact-1", Active = true, TermsNetDays = 30, TaxRate = taxRate, TaxComponentsJson = components, DefaultDiscountPercent = discount, TaxExempt = taxExempt }; + + private static InvoiceLineItem Line(decimal amount, bool taxable = true) => new InvoiceLineItem { Amount = amount, Quantity = 1, UnitRate = amount, Taxable = taxable, Description = "x" }; + + // ---------------------------------------------------------------- totals (decisions 12, 14, 23) + + [Test] + public void Totals_apply_discount_before_a_flat_tax_rate() + { + var invoice = new Invoice { DiscountPercent = 10m }; + InvoicingService.ComputeTotals(invoice, new[] { Line(1000m), Line(250m, taxable: false) }, Profile(taxRate: 8m)); + + invoice.SubTotal.Should().Be(1250.00m); + invoice.DiscountAmount.Should().Be(125.00m); + // Taxable base 1000 minus its pro-rata share of the discount (125 * 1000/1250 = 100) = 900; 8% = 72. + invoice.TaxAmount.Should().Be(72.00m); + invoice.Total.Should().Be(1197.00m); + invoice.TaxComponentsJson.Should().BeNull(); + } + + [Test] + public void Totals_snapshot_named_tax_components_with_amounts() + { + var invoice = new Invoice(); + var profile = Profile(components: "[{\"Name\":\"GST\",\"RatePercent\":5,\"RegistrationNumber\":\"123456789 RT0001\"},{\"Name\":\"BC PST\",\"RatePercent\":7,\"RegistrationNumber\":\"PST-1001-2345\"}]"); + InvoicingService.ComputeTotals(invoice, new[] { Line(200m) }, profile); + + invoice.TaxAmount.Should().Be(24.00m); + invoice.Total.Should().Be(224.00m); + var snapshot = InvoicingService.ParseTaxComponents(invoice.TaxComponentsJson); + snapshot.Select(x => (x.Name, x.Amount)).Should().Equal(("GST", 10.00m), ("BC PST", 14.00m)); + } + + [Test] + public void Tax_exempt_profile_and_discount_larger_than_subtotal_are_clamped() + { + var invoice = new Invoice { DiscountPercent = 100m }; + InvoicingService.ComputeTotals(invoice, new[] { Line(10m) }, Profile(taxRate: 20m, taxExempt: true)); + invoice.DiscountAmount.Should().Be(10.00m); + invoice.TaxAmount.Should().Be(0m); + invoice.Total.Should().Be(0m); + } + + [Test] + public void Amount_paid_counts_effective_payments_only() + { + var invoice = new Invoice(); + var payments = new[] + { + new InvoicePayment { Amount = 100m }, + new InvoicePayment { Amount = 50m, RefundedAmount = 20m, Status = (int)InvoicePaymentStatuses.PartiallyRefunded }, + new InvoicePayment { Amount = 70m, Status = (int)InvoicePaymentStatuses.DisputeLost } + }; + InvoicingService.ComputeTotals(invoice, new[] { Line(500m) }, Profile(), payments); + invoice.AmountPaid.Should().Be(130.00m); + } + + // ---------------------------------------------------------------- status derivation (decision 6) + + [TestCase(0, 100, 0, false, InvoiceStatus.Sent)] + [TestCase(0, 100, 0, true, InvoiceStatus.Overdue)] + [TestCase(0, 100, 40, true, InvoiceStatus.PartiallyPaid)] + [TestCase(0, 100, 100, true, InvoiceStatus.Paid)] + [TestCase(0, 100, 120, false, InvoiceStatus.Paid)] + public void Status_is_derived_from_balance_and_due_date(int _, decimal total, decimal paid, bool pastDue, InvoiceStatus expected) + { + var now = new DateTime(2026, 9, 18, 12, 0, 0, DateTimeKind.Utc); + var invoice = new Invoice { Status = (int)InvoiceStatus.Sent, Total = total, AmountPaid = paid, DueOn = pastDue ? now.AddDays(-1) : now.AddDays(10) }; + InvoicingService.DeriveStatus(invoice, now).Should().Be((int)expected); + } + + [Test] + public void Draft_and_void_never_move_through_payment_derivation() + { + var now = DateTime.UtcNow; + InvoicingService.DeriveStatus(new Invoice { Status = (int)InvoiceStatus.Void, Total = 10, AmountPaid = 10 }, now).Should().Be((int)InvoiceStatus.Void); + InvoicingService.DeriveStatus(new Invoice { Status = (int)InvoiceStatus.Draft, Total = 10, AmountPaid = 10 }, now).Should().Be((int)InvoiceStatus.Draft); + } + + // ---------------------------------------------------------------- time on scene (decision 9) + + [Test] + public void On_scene_minutes_run_from_first_on_scene_to_the_next_state() + { + var t0 = new DateTime(2026, 9, 18, 8, 0, 0, DateTimeKind.Utc); + var states = new List + { + new UnitState { UnitId = 1, State = (int)UnitStateTypes.Responding, Timestamp = t0 }, + new UnitState { UnitId = 1, State = (int)UnitStateTypes.OnScene, Timestamp = t0.AddMinutes(12) }, + new UnitState { UnitId = 1, State = (int)UnitStateTypes.Available, Timestamp = t0.AddMinutes(95) } + }; + InvoicingService.OnSceneMinutes(states, t0, t0.AddHours(3)).Should().Be(83); + } + + [Test] + public void On_scene_minutes_fall_back_to_the_call_window_and_never_go_negative() + { + var t0 = new DateTime(2026, 9, 18, 8, 0, 0, DateTimeKind.Utc); + InvoicingService.OnSceneMinutes(new List(), t0, t0.AddMinutes(45)).Should().Be(45); + InvoicingService.OnSceneMinutes(new List(), t0, t0.AddMinutes(-5)).Should().Be(0); + } + + [TestCase(83, 0, 0, 1.3833)] + [TestCase(83, 60, 0, 1.3833)] + [TestCase(83, 120, 0, 2.0)] + [TestCase(83, 0, 15, 1.5)] + [TestCase(83, 0, 30, 1.5)] + [TestCase(61, 0, 30, 1.5)] + [TestCase(5, 60, 30, 1.0)] + public void Rounding_and_minimum_minutes_produce_billable_hours(int minutes, int minimum, int rounding, decimal expectedHours) + { + var item = new RateCardItem { MinimumMinutes = minimum == 0 ? null : minimum, RoundingMinutes = rounding == 0 ? null : rounding }; + InvoicingService.ApplyRounding(minutes, item).Should().Be(expectedHours); + } + + // ---------------------------------------------------------------- create / send / void + + [Test] + public async Task Create_draft_assigns_the_next_number_and_the_profile_discount_and_publishes_created() + { + _profiles.Setup(p => p.GetByContactIdAsync("contact-1", 7)).ReturnsAsync(Profile(discount: 10m)); + _sequence.Setup(s => s.GetNextNumberAsync(7, It.IsAny())).ReturnsAsync(1042); + + var invoice = await Build().CreateDraftInvoiceAsync(7, "contact-1", "user-1", "127.0.0.1", "test", "cad"); + + invoice.InvoiceNumber.Should().Be(1042); + invoice.Status.Should().Be((int)InvoiceStatus.Draft); + invoice.DiscountPercent.Should().Be(10m); + invoice.Currency.Should().Be("CAD"); + invoice.CustomerBillingProfileId.Should().Be("profile-1"); + _published.Select(x => x.Trigger).Should().Equal(WorkflowTriggerEventType.InvoiceCreated); + _published.Single().AggregateId.Should().Be(invoice.InvoiceId); + _audits.Select(a => a.Type).Should().Equal(AuditLogTypes.InvoiceCreated); + } + + [Test] + public async Task Create_draft_requires_an_active_billing_profile() + { + _profiles.Setup(p => p.GetByContactIdAsync("contact-1", 7)).ReturnsAsync((CustomerBillingProfile)null); + var act = async () => await Build().CreateDraftInvoiceAsync(7, "contact-1", "user-1", null, null); + await act.Should().ThrowAsync().WithMessage("invoicing_profile_required"); + _sequence.Verify(s => s.GetNextNumberAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Mark_sent_needs_lines_sets_dates_from_terms_and_publishes_sent() + { + var invoice = new Invoice { InvoiceId = "inv-1", DepartmentId = 7, Status = (int)InvoiceStatus.Draft, CustomerBillingProfileId = "profile-1", ContactId = "contact-1" }; + _invoices.Setup(r => r.GetByIdForDepartmentAsync("inv-1", 7)).ReturnsAsync(invoice); + _profiles.Setup(p => p.GetByIdForDepartmentAsync("profile-1", 7)).ReturnsAsync(Profile(taxRate: 5m)); + _lineItems.Setup(l => l.GetByInvoiceIdAsync("inv-1", 7)).ReturnsAsync(new List()); + _payments.Setup(p => p.GetByInvoiceIdAsync("inv-1", 7)).ReturnsAsync(new List()); + + var noLines = async () => await Build().MarkSentAsync("inv-1", 7, null, "user-1", null, null); + await noLines.Should().ThrowAsync().WithMessage("invoicing_invoice_has_no_lines"); + + _lineItems.Setup(l => l.GetByInvoiceIdAsync("inv-1", 7)).ReturnsAsync(new List { Line(100m) }); + var sent = await Build().MarkSentAsync("inv-1", 7, "ap@acme.test", "user-1", null, null); + + sent.Status.Should().Be((int)InvoiceStatus.Sent); + sent.Total.Should().Be(105.00m); + sent.IssuedOn.Should().NotBeNull(); + sent.DueOn.Should().Be(sent.IssuedOn.Value.AddDays(30)); + sent.SentToEmail.Should().Be("ap@acme.test"); + _published.Select(x => x.Trigger).Should().Equal(WorkflowTriggerEventType.InvoiceSent); + } + + [Test] + public async Task Void_refuses_paid_invoices_and_publishes_voided_otherwise() + { + var paid = new Invoice { InvoiceId = "inv-p", DepartmentId = 7, Status = (int)InvoiceStatus.Paid, Total = 10, AmountPaid = 10 }; + _invoices.Setup(r => r.GetByIdForDepartmentAsync("inv-p", 7)).ReturnsAsync(paid); + var act = async () => await Build().VoidInvoiceAsync("inv-p", 7, "dup", "user-1", null, null); + await act.Should().ThrowAsync().WithMessage("invoicing_invoice_paid_cannot_void"); + + var sent = new Invoice { InvoiceId = "inv-s", DepartmentId = 7, Status = (int)InvoiceStatus.Sent, Total = 10, CustomerBillingProfileId = "profile-1", ContactId = "contact-1" }; + _invoices.Setup(r => r.GetByIdForDepartmentAsync("inv-s", 7)).ReturnsAsync(sent); + _lineItems.Setup(l => l.GetByInvoiceIdAsync("inv-s", 7)).ReturnsAsync(new List()); + _payments.Setup(p => p.GetByInvoiceIdAsync("inv-s", 7)).ReturnsAsync(new List()); + var voided = await Build().VoidInvoiceAsync("inv-s", 7, "duplicate", "user-1", null, null); + voided.Status.Should().Be((int)InvoiceStatus.Void); + voided.VoidReason.Should().Be("duplicate"); + _published.Select(x => x.Trigger).Should().Equal(WorkflowTriggerEventType.InvoiceVoided); + } + + // ---------------------------------------------------------------- payments (the choke point) + + private Invoice SentInvoice(decimal total, params InvoicePayment[] existing) + { + var invoice = new Invoice { InvoiceId = "inv-1", DepartmentId = 7, Status = (int)InvoiceStatus.Sent, Total = total, CustomerBillingProfileId = "profile-1", ContactId = "contact-1", DueOn = DateTime.UtcNow.AddDays(10) }; + var stored = existing.ToList(); + _invoices.Setup(r => r.GetByIdForDepartmentAsync("inv-1", 7)).ReturnsAsync(invoice); + _payments.Setup(p => p.GetByInvoiceIdAsync("inv-1", 7)).ReturnsAsync(() => stored.ToList()); + _payments.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InvoicePayment p, CancellationToken _, bool __) => { p.InvoicePaymentId ??= Guid.NewGuid().ToString(); if (!stored.Contains(p)) stored.Add(p); return p; }); + return invoice; + } + + [Test] + public async Task Partial_then_full_payment_moves_sent_to_partially_paid_to_paid_and_publishes_paid_once() + { + var invoice = SentInvoice(100m); + var service = Build(); + + await service.RecordPaymentAsync(new InvoicePayment { InvoiceId = "inv-1", DepartmentId = 7, Amount = 40m, Method = (int)InvoicePaymentMethods.Check, Reference = "1001" }, "user-1", null, null); + invoice.Status.Should().Be((int)InvoiceStatus.PartiallyPaid); + invoice.AmountPaid.Should().Be(40m); + invoice.PaidOn.Should().BeNull(); + + await service.RecordPaymentAsync(new InvoicePayment { InvoiceId = "inv-1", DepartmentId = 7, Amount = 60m, Method = (int)InvoicePaymentMethods.Cash }, "user-1", null, null); + invoice.Status.Should().Be((int)InvoiceStatus.Paid); + invoice.AmountPaid.Should().Be(100m); + invoice.PaidOn.Should().NotBeNull(); + + _published.Select(x => x.Trigger).Should().Equal(WorkflowTriggerEventType.InvoicePaymentRecorded, WorkflowTriggerEventType.InvoicePaymentRecorded, WorkflowTriggerEventType.InvoicePaid); + _audits.Select(a => a.Type).Should().Equal(AuditLogTypes.InvoicePaymentRecorded, AuditLogTypes.InvoicePaymentRecorded); + } + + [Test] + public async Task Online_payment_replays_are_idempotent_on_the_gateway_transaction_id() + { + SentInvoice(100m); + var first = new InvoicePayment { InvoicePaymentId = "pay-1", InvoiceId = "inv-1", DepartmentId = 7, Amount = 100m, Method = (int)InvoicePaymentMethods.Online, Provider = (int)PaymentProviders.Stripe, GatewayTransactionId = "pi_123" }; + _payments.Setup(p => p.GetByGatewayTransactionIdAsync((int)PaymentProviders.Stripe, "pi_123")).ReturnsAsync(first); + + var result = await Build().RecordPaymentAsync(new InvoicePayment { InvoiceId = "inv-1", DepartmentId = 7, Amount = 100m, Method = (int)InvoicePaymentMethods.Online, Provider = (int)PaymentProviders.Stripe, GatewayTransactionId = "pi_123" }, null, null, null); + + result.Should().BeSameAs(first); + _published.Should().BeEmpty(); + _payments.Verify(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Draft_and_void_invoices_are_not_payable() + { + var draft = new Invoice { InvoiceId = "inv-d", DepartmentId = 7, Status = (int)InvoiceStatus.Draft, Total = 10 }; + _invoices.Setup(r => r.GetByIdForDepartmentAsync("inv-d", 7)).ReturnsAsync(draft); + var act = async () => await Build().RecordPaymentAsync(new InvoicePayment { InvoiceId = "inv-d", DepartmentId = 7, Amount = 5, Method = 0 }, "u", null, null); + await act.Should().ThrowAsync().WithMessage("invoicing_invoice_not_payable"); + } + + [Test] + public async Task Refund_reopens_the_balance_and_a_lost_dispute_removes_the_payment_entirely() + { + var payment = new InvoicePayment { InvoicePaymentId = "pay-1", InvoiceId = "inv-1", DepartmentId = 7, Amount = 100m, Method = (int)InvoicePaymentMethods.Online, PaidOn = DateTime.UtcNow }; + var invoice = SentInvoice(100m, payment); + invoice.Status = (int)InvoiceStatus.Paid; invoice.AmountPaid = 100m; invoice.PaidOn = DateTime.UtcNow; + _payments.Setup(p => p.GetByIdForDepartmentAsync("pay-1", 7)).ReturnsAsync(payment); + var service = Build(); + + await service.ApplyPaymentRefundAsync("pay-1", 7, 30m, false, "user-1", null, null); + payment.Status.Should().Be((int)InvoicePaymentStatuses.PartiallyRefunded); + invoice.AmountPaid.Should().Be(70m); + invoice.Status.Should().Be((int)InvoiceStatus.PartiallyPaid); + invoice.PaidOn.Should().BeNull(); + + await service.ApplyPaymentRefundAsync("pay-1", 7, 100m, true, "user-1", null, null); + payment.Status.Should().Be((int)InvoicePaymentStatuses.DisputeLost); + invoice.AmountPaid.Should().Be(0m); + invoice.Status.Should().Be((int)InvoiceStatus.Sent); + + _published.Select(x => x.Trigger).Should().Equal(WorkflowTriggerEventType.InvoicePaymentRefunded, WorkflowTriggerEventType.InvoicePaymentDisputed); + } + + // ---------------------------------------------------------------- overdue sweep + aging + + [Test] + public async Task Overdue_sweep_transitions_only_sent_and_partially_paid_and_publishes_each_once() + { + var asOf = new DateTime(2026, 9, 18, 0, 0, 0, DateTimeKind.Utc); + var candidates = new List + { + new Invoice { InvoiceId = "a", DepartmentId = 7, Status = (int)InvoiceStatus.Sent, DueOn = asOf.AddDays(-1), ContactId = "contact-1" }, + new Invoice { InvoiceId = "b", DepartmentId = 7, Status = (int)InvoiceStatus.PartiallyPaid, DueOn = asOf.AddDays(-9), ContactId = "contact-1" }, + new Invoice { InvoiceId = "c", DepartmentId = 7, Status = (int)InvoiceStatus.Paid, DueOn = asOf.AddDays(-9), ContactId = "contact-1" } + }; + _invoices.Setup(r => r.GetOverdueCandidatesAsync(asOf, It.IsAny())).ReturnsAsync(candidates); + + var count = await Build().MarkOverdueInvoicesAsync(asOf); + + count.Should().Be(2); + candidates.Take(2).Select(x => x.Status).Should().AllBeEquivalentTo((int)InvoiceStatus.Overdue); + candidates[2].Status.Should().Be((int)InvoiceStatus.Paid); + _published.Select(x => x.Trigger).Should().Equal(WorkflowTriggerEventType.InvoiceOverdue, WorkflowTriggerEventType.InvoiceOverdue); + } + + [Test] + public async Task Aging_buckets_open_balances_by_days_past_due() + { + var asOf = new DateTime(2026, 9, 18, 0, 0, 0, DateTimeKind.Utc); + _invoices.Setup(r => r.GetAgingDataAsync(7)).ReturnsAsync(new List + { + new InvoiceAgingRow { InvoiceId = "1", Total = 100, AmountPaid = 0, DueOn = asOf.AddDays(5) }, + new InvoiceAgingRow { InvoiceId = "2", Total = 100, AmountPaid = 25, DueOn = asOf.AddDays(-10) }, + new InvoiceAgingRow { InvoiceId = "3", Total = 100, AmountPaid = 0, DueOn = asOf.AddDays(-45) }, + new InvoiceAgingRow { InvoiceId = "4", Total = 100, AmountPaid = 0, DueOn = asOf.AddDays(-75) }, + new InvoiceAgingRow { InvoiceId = "5", Total = 100, AmountPaid = 0, DueOn = asOf.AddDays(-200) }, + new InvoiceAgingRow { InvoiceId = "6", Total = 100, AmountPaid = 100, DueOn = asOf.AddDays(-200) } + }); + + var report = await Build().GetAccountsReceivableAgingAsync(7, asOf); + + report.Buckets.Select(b => (b.Label, b.Count, b.Balance)).Should().Equal(("Current", 1, 100m), ("1-30", 1, 75m), ("31-60", 1, 100m), ("61-90", 1, 100m), ("90+", 1, 100m)); + report.TotalBalance.Should().Be(475m); + report.TotalCount.Should().Be(5); + } + + // ---------------------------------------------------------------- rendering + delivery (B4) + + [Test] + public void Render_shows_department_identity_customer_lines_discount_tax_components_and_balance() + { + var invoice = new Invoice + { + InvoiceId = "inv-1", InvoiceNumber = 1042, Status = (int)InvoiceStatus.PartiallyPaid, Currency = "CAD", SubTotal = 1000m, DiscountPercent = 10m, DiscountAmount = 100m, + TaxAmount = 108m, Total = 1008m, AmountPaid = 500m, IssuedOn = new DateTime(2026, 9, 18), DueOn = new DateTime(2026, 10, 18), TermsText = "Net 30", + TaxComponentsJson = "[{\"Name\":\"GST\",\"RatePercent\":5,\"RegistrationNumber\":\"123456789 RT0001\",\"Amount\":45},{\"Name\":\"BC PST\",\"RatePercent\":7,\"RegistrationNumber\":\"PST-1001\",\"Amount\":63}]", + LineItems = new List { new InvoiceLineItem { Description = "Engine 1 standby } + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml index ea774c4eb..2240f4d12 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml @@ -1,6 +1,7 @@ @model Resgrid.Web.Areas.User.Models.Contacts.ViewContactView @inject IStringLocalizer localizer @inject IStringLocalizer commonLocalizer +@inject IStringLocalizer invoicingLocalizer @using System.Linq @{ ViewBag.Title = "Resgrid | " + @localizer["ViewContactHeader"]; @@ -60,6 +61,14 @@ @localizer["SiteFiles"] @(Model.Attachments?.Count ?? 0) + @if (Model.InvoicingAvailable) + { + + } @if (Model.RouteStops != null && Model.RouteStops.Count > 0) { } + @if (ClaimsAuthorizationHelper.CanViewInvoicing() && SettingsHelper.IsBusinessOperationsEnabled() && await featureToggleService.IsEnabledAsync(FeatureFlagKeys.CustomerInvoicing, ClaimsAuthorizationHelper.GetDepartmentId())) + { + @* Workforce & Business Operations plan, Phase B: visible whenever the flag and module are on; a lapsed add-on leaves the pages read-only. *@ +
  • + @invoicingLocalizer["Invoicing"] +
  • + } @if (SettingsHelper.IsMappingEnabled()) {
  • diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index 8746877c2..9e26b6712 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -538,6 +538,7 @@

    Advanced Data Protection encrypts your department's sensitive fields with a key held only for your department, and hides them behind a second-factor check. It is available on paid plans, billed yearly, and only your department's managing member can buy or cancel it. Buying the addon does not encrypt anything on its own — you enroll afterwards, from the Data Protection settings page, at a time you choose.

    Manage Data Protection Readiness Pro + Business Operations diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml index c66355e1a..849e80fac 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml @@ -1,5 +1,6 @@ @inject IStringLocalizer inventoryStrings @inject IStringLocalizer workOrderStrings +@inject IStringLocalizer invoicingStrings @inject IStringLocalizer checklistStrings @model Resgrid.Model.Workflow @inject IStringLocalizer localizer @@ -7,7 +8,7 @@ ViewData["Title"] = "Resgrid | " + localizer["EditWorkflowHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var credentialsJson = (string)(ViewBag.CredentialsJson ?? "[]"); - var triggerEventTypeName = Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(Model.TriggerEventType) ? inventoryStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder(Model.TriggerEventType) ? workOrderStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); + var triggerEventTypeName = Resgrid.Model.Invoicing.InvoiceWorkflowPayload.IsInvoice(Model.TriggerEventType) ? invoicingStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(Model.TriggerEventType) ? inventoryStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder(Model.TriggerEventType) ? workOrderStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); } @section Styles { diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml index b61346341..48d065bb8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml @@ -1,5 +1,6 @@ @inject IStringLocalizer inventoryStrings @inject IStringLocalizer workOrderStrings +@inject IStringLocalizer invoicingStrings @inject IStringLocalizer checklistStrings @model Resgrid.Model.Workflow @inject IStringLocalizer localizer @@ -12,7 +13,7 @@ .Cast() .Where(e => !usedEventTypes.Contains((int)e)) .Where(e => recordsTriggersAvailable || !Resgrid.Model.WorkflowTriggerEventTypes.IsRecordsTrigger(e)) - .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory((int)e) ? inventoryStrings[e.ToString()].Value : Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder((int)e) ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) + .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.Invoicing.InvoiceWorkflowPayload.IsInvoice((int)e) ? invoicingStrings[e.ToString()].Value : Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory((int)e) ? inventoryStrings[e.ToString()].Value : Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder((int)e) ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) .ToList(); var noEventTypesAvailable = !eventTypes.Any(); } diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 2788025db..2c4eccb71 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -315,6 +315,8 @@ public static bool CanDeleteContacts() public static bool CanManageWorkOrders() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.Update); public static bool CanViewAllWorkOrders() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.View); + public static bool CanManageInvoicing() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Invoicing, ResgridClaimTypes.Actions.Update); + public static bool CanViewInvoicing() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Invoicing, ResgridClaimTypes.Actions.View); public static bool CanManageChecklists() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update); public static bool CanViewChecklistResults() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View); diff --git a/Web/Resgrid.Web/Helpers/SettingsHelper.cs b/Web/Resgrid.Web/Helpers/SettingsHelper.cs index 9175261cc..346a2801b 100644 --- a/Web/Resgrid.Web/Helpers/SettingsHelper.cs +++ b/Web/Resgrid.Web/Helpers/SettingsHelper.cs @@ -93,6 +93,12 @@ public static bool IsMaintenanceEnabled() return !GetModuleSettings().MaintenanceDisabled; } + /// Business Operations module switch (Workforce & Business Operations plan, decision 42); the add-on entitlement is checked separately. + public static bool IsBusinessOperationsEnabled() + { + return !GetModuleSettings().BusinessOperationsDisabled; + } + public static ResolvedMapConfig GetDepartmentMapConfig(string key = null) { var requestedKey = string.IsNullOrWhiteSpace(key) ? InfoConfig.WebsiteKey : key; diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index 59e711600..9d2ed11c5 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -256,6 +256,10 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.RecordRestricted_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordRestricted, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.WorkOrder_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.WorkOrder_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Invoicing_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Invoicing, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Invoicing_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Invoicing, ResgridClaimTypes.Actions.Create)); + options.AddPolicy(ResgridResources.Invoicing_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Invoicing, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.Invoicing_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Invoicing, ResgridClaimTypes.Actions.Delete)); options.AddPolicy(ResgridResources.Checklist_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.ChecklistResults_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordDefinition_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/invoicing/business-operations-billing.js b/Web/Resgrid.Web/wwwroot/js/app/internal/invoicing/business-operations-billing.js new file mode 100644 index 000000000..3e6973069 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/invoicing/business-operations-billing.js @@ -0,0 +1,30 @@ +(function () { + 'use strict'; + // Business Operations add-on checkout (clone of readiness-billing.js): the server hands back either a Stripe + // Checkout URL (host pinned to checkout.stripe.com) or a Paddle transaction id; anything else shows the error line. + var form = document.getElementById('business-operations-checkout'); + if (!form) return; + var config = JSON.parse(document.getElementById('business-operations-billing-config').textContent); + form.addEventListener('submit', async function (event) { + event.preventDefault(); + var button = form.querySelector('button'); + if (button.disabled) return; + button.disabled = true; + try { + var response = await fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin', cache: 'no-store' }); + if (!response.ok) throw new Error(); + var result = await response.json(); + if (config.provider === 'Stripe' && result.url) { + var url = new URL(result.url); + if (url.protocol !== 'https:' || url.hostname !== 'checkout.stripe.com' || url.username) throw new Error(); + window.location.assign(url.href); + } else if (config.provider === 'Paddle' && /^txn_[a-z0-9]{26}$/.test(result.transactionId) && window.Paddle && config.token) { + if (config.environment === 'sandbox') Paddle.Environment.set('sandbox'); + Paddle.Initialize({ token: config.token }); + Paddle.Checkout.open({ transactionId: result.transactionId }); + } else throw new Error(); + } catch (_) { + var error = document.getElementById('business-operations-billing-error'); error.textContent = config.error; error.hidden = false; + } finally { button.disabled = false; } + }); +}()); diff --git a/Workers/Resgrid.Workers.Console/Commands/InvoiceMaintenanceCommand.cs b/Workers/Resgrid.Workers.Console/Commands/InvoiceMaintenanceCommand.cs new file mode 100644 index 000000000..61bc15caa --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/InvoiceMaintenanceCommand.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 29 (Identifier Allocation Registry, Workforce & Business Operations plan B5): invoice maintenance. + public sealed class InvoiceMaintenanceCommand : IQuidjiboCommand + { + public InvoiceMaintenanceCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index fd5a76783..c6054b3a6 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -506,6 +506,14 @@ await Client.ScheduleAsync("Records Search Index", Cron.MinuteIntervals(1), stoppingToken); + // Worker ID 29 (Identifier Allocation Registry, Workforce & Business Operations plan B5): invoice maintenance — + // overdue sweep now; Phase B2 payment-request reconciliation, connection re-verification and event purge later. + _logger.Log(LogLevel.Information, "Scheduling Invoice Maintenance"); + await Client.ScheduleAsync("Invoice Maintenance", + new Commands.InvoiceMaintenanceCommand(29), + Cron.MinuteIntervals(15), + stoppingToken); + // Worker ID 70 (Identifier Allocation Registry section 4F, Unified Search): global search index maintenance. Same // single-writer process as 44; no-op while SearchConfig.Enabled is off; departments enter lazily via their state row. _logger.Log(LogLevel.Information, "Scheduling Search Index"); diff --git a/Workers/Resgrid.Workers.Console/Tasks/InvoiceMaintenanceTask.cs b/Workers/Resgrid.Workers.Console/Tasks/InvoiceMaintenanceTask.cs new file mode 100644 index 000000000..2cff8a7b7 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/InvoiceMaintenanceTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class InvoiceMaintenanceTask : IQuidjiboHandler + { + public string Name => "Invoice Maintenance"; + public int Priority => 1; + public async Task ProcessAsync(InvoiceMaintenanceCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new InvoiceMaintenanceLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/InvoiceMaintenanceLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/InvoiceMaintenanceLogic.cs new file mode 100644 index 000000000..b7e28f7d0 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/InvoiceMaintenanceLogic.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker 29 (Workforce & Business Operations plan B5), every 15 minutes. Pass 1: Sent / PartiallyPaid invoices + /// past DueOn become Overdue (idempotent; each transition publishes InvoiceOverdue once). Passes 2–4 (payment + /// request reconciliation, connection re-verification, request expiry, event purge) arrive with Phase B2 (M0212). + /// Cheap when no department has invoicing on: a single indexed query returns nothing. + /// + public sealed class InvoiceMaintenanceLogic + { + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var invoicing = scope.Resolve(); + var access = scope.Resolve(); + var overdue = await invoicing.MarkOverdueInvoicesAsync(DateTime.UtcNow, access.CanUseInvoicingAsync, ct); + return Tuple.Create(true, $"Invoice maintenance: overdue={overdue}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Invoice maintenance worker failed."); + return Tuple.Create(false, "Invoice maintenance failed."); + } + } + } +}