ثلاث إشارات، واحدة فقط موثوقة
مع صفحة الدفع المستضافة لا تلمس واجهتك البرمجية بيانات البطاقة. تنشئ طلبًا لدى Telr، وترسل العميل إلى الصفحة التي يعيدها، ثم تنتظر النتيجة. والنتيجة تصلك بثلاث طرق لا تتساوى.
- رابط العودة. يعود المتصفح إلى صفحة القبول أو الرفض أو الإلغاء عندك. مفيد للمستخدم، وبلا قيمة كدليل: أي شخص يستطيع كتابة هذا الرابط، والتبويب المغلق لا يعود أصلًا.
- إشعار Telr. يرسل Telr إلى خادمك إشعارًا موقّعًا بصيغة نموذج (ivp_update_url). دليل جيد بعد التحقق من التوقيع، لكنه قد يتأخر، أو يصل مرتين، أو يسبق المتصفح.
- order/check. خادمك يسأل Telr عن الطلب بمرجعه. هذه هي الإجابة التي تبني عليها، وإليها ترجع كلما اختلفت الإشارتان الأخريان أو سكتتا.
أنشئ الجلسة مرة واحدة
نقرة مزدوجة على زر الدفع، أو إعادة محاولة من اتصال جوال ضعيف، أو تبويبان مفتوحان: كلها تطلب جلسة دفع ثانية للطلب نفسه. وجلستان حيّتان تعنيان أن العميل قد يدفع مرتين. اقفل صف الطلب، وأعد استخدام الجلسة إن كانت محفوظة، واحفظ المرجع والرابط الجديدين في المعاملة نفسها التي أنشأتهما.
أعد محاولة عمل قاعدة البيانات كما تشاء. لا تعد محاولة الاتصال بالبوابة أبدًا. إن فشل الاتصال بعد خروج الطلب فأنت لا تعرف هل فتح Telr جلسة أم لا، فيوضع الطلب في الانتظار ويُطلب من العميل ألا يبدأ دفعة أخرى. أما الرفض الصريح من Telr فشيء آخر: احذف الطلب وحرّر المقاعد. وضع مهلة قصيرة لاتصال Telr، لأن صف الطلب يبقى مقفولًا وأنت تنتظر.
تفصيل مهم: مرجع الصفحة المستضافة يُستهلك عند أول تحميل لها. إذا عاد العميل نفسه لاحقًا ليدفع السلة نفسها، فاسأل Telr عن الجلسة القديمة أولًا، واستبدلها فقط إن كانت ما زالت معلّقة.
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
// UPDLOCK: two requests cannot both reach Telr before one has saved its session.
var order = await db.Orders
.FromSqlInterpolated($"SELECT * FROM Orders WITH (UPDLOCK, HOLDLOCK) WHERE Id = {orderId}")
.AsTracking()
.SingleAsync(ct);
if (order.PaymentUrl is not null && order.PaymentOrderRef is not null)
return new PaymentSession(order.PaymentUrl, order.PaymentOrderRef);
try
{
var session = await telr.CreateSessionAsync(order, ct); // method: "create"
order.PaymentUrl = session.Url;
order.PaymentOrderRef = session.Ref;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return session;
}
catch (Exception ex) when (ex is not GatewayRejectedException)
{
// The request may have reached Telr: hold the order, never open a second session.
throw new PaymentSessionUncertainException(ex);
} جلسة دفع مستضافة واحدة لكل طلب (EF Core وSQL Server) المبالغ نصوص، والإعدادات الإقليمية تخدعك
يستقبل Telr المبلغ نصًا. يبدو ToString("F2") آمنًا حتى يعمل الخادم بإعدادات إقليمية تكتب الفاصلة العشرية فاصلة، فيصبح المبلغ شيئًا غير السعر الذي قصدته. نسّق بالثقافة الثابتة (InvariantCulture)، واحتفظ باختبار وحدة يغيّر ثقافة الخيط إلى fr-FR ليثبت ذلك.
قرّب حيث يُحسم المال، لا في النهاية. ضريبة تُقرَّب لكل تذكرة بعيدًا عن الصفر، وإجمالي الطلب هو مجموع البنود المقرّبة، يعني أن الرقم الذي ترسله هو نفسه الرقم في قاعدة بياناتك. نسختنا الأولى حسبت الضريبة بـdouble، والآن decimal في كل مكان.
foreach (var item in order.Items)
item.TaxAmount = Math.Round(item.Price * taxRate, 2, MidpointRounding.AwayFromZero);
order.TotalAmount = order.Items.Sum(i => i.Price + i.TaxAmount);
var amount = order.TotalAmount.ToString("F2", CultureInfo.InvariantCulture); // "11.50", never "11,50" المبلغ الذي يراه Telr، والإجمالي الذي يجب أن يطابقه ثق في order/check، لا في إعادة التوجيه
صفحة العودة لا تقرر شيئًا. تقرأ رقم الطلب من رابطها وتطلب من واجهتك البرمجية التحقق، عدة مرات، بفاصل ثانيتين تقريبًا. تتأكد الواجهة أن الطلب يخص المستخدم المسجّل، وتستدعي order/check بالمرجع المحفوظ، ثم تقارن وتطبّق.
قارن كل ما تستطيع: المرجع، ورقم السلة، والمبلغ، والعملة، وعلامة الاختبار. إجابة من وضع الاختبار تصل إلى بيئة الإنتاج، أو دفعة بعملة خاطئة، خطأ في الإعدادات يجب أن يفشل بصوت عالٍ، لا أن يحوّل الطلب إلى مدفوع.
| order/check | المعنى | حالة الطلب |
|---|---|---|
| 3، والمعاملة A، بيع أو تحصيل | مدفوع | مقبول: نفّذ الطلب |
| 2 | مفوَّض، لم يُحصَّل | في الانتظار |
| 1 | معلّق | معلّق: اسأل لاحقًا |
| -1 أو -2 | منتهٍ أو ملغى | ملغى |
| -3 | مرفوض | مرفوض |
| غير ذلك | غير معروف | خطأ، وتنبيه |
إشعار Telr: تحقق منه، ثم أجب دائمًا بـ200
يرسل Telr الإشعار بصيغة نموذج، فاستقبله كنموذج. يوقّع مفتاحك السري وأربعة عشر حقلًا من حقوله بـSHA1، تفصل بينها نقطتان، والمفتاح أولًا. تحقق من رقم المتجر وعلامة الاختبار والعملة مقابل إعداداتك قبل أن تحسب أي توقيع، وقارن التوقيع في زمن ثابت.
ثم أجب بـ200، مهما حدث. التوقيع الخاطئ، أو الطلب غير المعروف، أو المبلغ غير المطابق يُسجَّل، والمبلغ غير المطابق كخطأ حرج، لكن رد الخطأ لا يفعل إلا أن يخبر الغريب بما تتحقق منه، ويستدعي إعادة محاولات لن تنجح أبدًا.
if (f.tran_store != settings.StoreId || f.tran_test != settings.Test || f.tran_currency != settings.Currency)
return false;
var signed = string.Join(":", settings.SecretKey, f.tran_store, f.tran_type, f.tran_class, f.tran_test,
f.tran_ref, f.tran_prevref, f.tran_firstref, f.tran_currency, f.tran_amount, f.tran_cartid,
f.tran_desc, f.tran_status, f.tran_authcode, f.tran_authmessage);
var expected = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(signed))); // SHA1 is Telr's choice
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(f.tran_check ?? "")); التحقق من إشعار المعاملة طبّق النتيجة مرة واحدة فقط
إشعار Telr وطلب التحقق من المتصفح يصلان غالبًا في الثانية نفسها، وكلاهما يريد أن يجعل الطلب مدفوعًا، ويحسب المقاعد مبيعة، ويفرغ السلة. القراءة ثم الكتابة تسمح بمرور الاثنين. التحديث المشروط يسمح بمرور واحد فقط: لا يطابق إلا ما دام الطلب معلّقًا أو في الانتظار.
- عدد المبيعات يتحرك بزيادة ذرية في SQL، لا بقراءة ثم جمع ثم حفظ. إشعاران متزامنان كان يكتب أحدهما فوق الآخر.
- إن كانت المقاعد المحجوزة قد ذهبت عند وصول الدفعة، يوضع الطلب في الانتظار للمراجعة بدل تنفيذه بمقاعد شخص آخر.
- تُحذف من السلة البنود التي دُفعت فقط. قد يكون العميل يتصفح فعالية أخرى أثناء الدفع.
- تنفيذ الطلب يعيش في خدمة واحدة. كان لدينا يومًا ثلاثة معالجات تفعل ذلك، لكل منها خطأ مختلف.
var claimed = await db.Orders
.Where(o => o.Id == orderId &&
(o.PaymentStatus == PaymentStatus.Pending || o.PaymentStatus == PaymentStatus.OnHold))
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.PaymentStatus, status)
.SetProperty(o => o.PaymentTranRef, tranRef), ct);
if (claimed == 0)
{
// Someone else already settled it. A "paid" for an order we cancelled goes to a person.
if (status == PaymentStatus.Authorized && current is PaymentStatus.Cancelled or PaymentStatus.Declined)
logger.LogCritical("Late payment for released order {OrderId}: manual review", orderId);
return;
} احجز الطلب، ثم نفّذ سوِّ ما انتهت مهلته
تُحجز المقاعد 30 دقيقة. حين ينتهي الحجز يبدو الحل المغري أن تلغي الطلب وتحرر المقاعد. لكن انتهاء المهلة عندك ليس دليلًا على أن البوابة لم تعد قادرة على الخصم: قد يكون العميل في صفحة الدفع الآن.
تعمل مهمة خلفية كل خمس دقائق. الطلبات المنتهية التي لها مرجع في Telr يُسأل عنها order/check أولًا، ويُطبَّق ما يقوله Telr. والطلبات التي لم تصل إلى Telr أصلًا تُلغى. وإن فشل السؤال يبقى الحجز حتى الدورة التالية. والطلبات في الانتظار تنتهي أيضًا، وإلا حجزت المقاعد إلى الأبد.
الجوال: التطبيق لا يقرأ الرابط
دفع الويب يعيد توجيه الصفحة كلها (framed 0). تطبيق Flutter يفتح الصفحة المستضافة نفسها في WebView ويراقب العودة إلى نطاقه، ثم يغلق WebView ويطلب من الواجهة البرمجية التحقق. لا يقرر النجاح أبدًا من الرابط الذي عاد إليه، للسبب نفسه الذي لا تفعل صفحة الويب ذلك من أجله.
المفاتيح في البيئة، ونشر يرفض وضع الاختبار
رقم المتجر ومفتاح المصادقة والمفتاح السري في متغيرات البيئة (Telr__StoreId وأخواتها)، لا في ملفات appsettings التي تنتهي في مستودع. وسكربت النشر للإنتاج يرفض الإطلاق إن كانت البيئة ما زالت في وضع الاختبار أو كانت روابط العودة تشير إلى مكان غير متوقع.
grep -Fxq 'Telr__Test=0' .env || { echo 'Telr is still in test mode'; exit 1; } سطر واحد في سكربت النشر قبل أن تستقبل مالًا حقيقيًا
- هل يُنسَّق المبلغ بالثقافة الثابتة، وهل يساوي مجموع البنود المقرّبة؟
- هل يمكن لنقرة مزدوجة أو إعادة محاولة أن تفتح جلسة ثانية للطلب نفسه؟
- هل تسأل صفحة العودة خادمك، وهل يسأل خادمك Telr؟
- هل يتحقق الإشعار من المتجر وعلامة الاختبار والعملة والتوقيع والمبلغ، ثم يجيب بـ200 رغم ذلك؟
- هل يمكن للإشعار ولطلب التحقق أن ينفّذا الطلب نفسه مرتين؟
- ماذا يحدث لدفعة تصل بعد انتهاء الحجز؟
- هل المفاتيح خارج المستودع، وهل يمكن للإنتاج أن يعمل في وضع الاختبار بالخطأ؟