محرك الاشتراكات في lahsab هو Stripe حيث collectionMethod يساوي دائما send_invoice: تصدر فاتورة مؤرخة، وتوجه تذكيرات، ويدفع الزبون بتحويل CCP أو بريدي موب متى شاء. لا توجد بطاقة يخصم منها، فلا شيء يقتطع من تلقاء نفسه، ومن هذا المبدأ تنبع الآلية كلها.
القاعدة الذهبية
يستطيع Stripe بالبطاقة أن يقدم الساعة عند التجديد مباشرة، لأن لديه بطاقة يخصم منها: الدفع هناك مفترض. أما lahsab فلا يملك ما يخصم منه. تقديم الفترة على فاتورة صدرت ولم تدفع يعني منح الوصول مقابل وعد، وإيهامك بأن الزبون في وضعية سليمة بينما المال لم يصل قط.
لذلك لا تتحرك الساعة أبدا على وعد. تتحرك على دفعة.
عمليا، يختزل كود الصلاحيات عندك في مقارنة واحدة:
const aAcces = user.paidUntil && user.paidUntil > new Date();
حيث paidUntil هو قيمة currentPeriodEnd التي سجلتها عند استقبال invoice.paid.
من يملك ماذا
هذه هي الحدود التي يجب استيعابها قبل كتابة أول سطر.
| ما يملكه lahsab | ما تملكه أنت | |
|---|---|---|
| ماذا | المال والوقت | الصلاحيات (entitlement) |
| أي | من يدين بماذا، عن أي فترة، هل دفع، ومنذ متى تأخر. | ما تفتحه كل خطة، والحصص، والحدود، والأدوار. |
lahsab لا يعرف ما تفتحه خطتك «Pro»: لا عدد المشاريع المسموح به ولا الميزات التي تتيحها. تماما مثل Stripe. نقول لك «مدفوع إلى غاية 13 سبتمبر»، وما تفعله بذلك شأنك أنت.
النموذج
خمسة كائنات مستعارة من Stripe: من يعرف أحدهما يعرف الآخر.
| الكائن | الدور |
|---|---|
| Customer | زبونك. externalId هو معرفه عندك (فريد لكل تاجر وبيئة). phone هو قناة التذكير. |
| Product | ما تبيعه. «اشتراك Pro» مثلا. |
| Price | تسعيرة لمنتج: المبلغ، one_time أو recurring، والوتيرة. غير قابل للتعديل. |
| Subscription | التزام زبون بسعر متكرر. يحمل currentPeriodEnd. |
| Invoice | ما هو مستحق، عن فترة، بتاريخ استحقاق. هذا هو الكائن الذي يدفع. |
الاشتراك بنداء واحد
POST /subscriptions هو نقطة الدخول لمسار الشراء. في نداء واحد ينشئ الاشتراك، ويصدر الفاتورة الأولى فورا، ويرجع لك الرابط الذي توجه إليه المشتري. إنه Stripe Checkout الخاص بك.
-
حدد الزبون. ابحث عنه بمعرفك الخاص (
GET /customers?externalId=)، وأنشئه إن لم يوجد. هذا هو نمط lookup / upsert. -
أنشئ الاشتراك. نداء
POST /subscriptionsمعcustomerIdوpriceId. يولد الاشتراك بحالةincompleteوتصدر الفاتورة الأولى. -
وجه المشتري إلى
latestInvoice.hostedInvoiceUrl، صفحة الفاتورة المستضافة لدى lahsab، حيث يختار وسيلة الدفع، ويجد بيانات الدفع الموافقة لها، ويودع وصل الدفع. -
انتظر
invoice.paid. هناك، وهناك فقط، يمنح الوصول: يتحول الاشتراك إلىactiveويتقدمcurrentPeriodEnd.
# 1. Le client existe-t-il déjà ?
curl "https://api.lahsab.com/customers?externalId=user_abc123" \
-H "x-api-key: lsk_live_CLE"
# 2. Sinon, on le crée.
curl -X POST https://api.lahsab.com/customers \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: cust-user_abc123" \
-d '{
"externalId": "user_abc123",
"name": "Yacine B.",
"phone": "0770000000",
"defaultMethod": "baridimob"
}' async function upsertCustomer(user) {
const found = await api(
`/customers?externalId=${encodeURIComponent(user.id)}`,
);
if (found.length) return found[0];
return api("/customers", {
method: "POST",
headers: { "Idempotency-Key": `cust-${user.id}` },
body: JSON.stringify({
externalId: user.id,
name: user.fullName,
phone: user.phone,
defaultMethod: "baridimob",
}),
});
} curl -X POST https://api.lahsab.com/subscriptions \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: sub-user_abc123-pro-mensuel" \
-d '{
"customerId": "c0ffee00-1111-4222-8333-444455556666",
"priceId": "9f8e7d6c-5b4a-4321-9876-543210fedcba",
"daysUntilDue": 7
}' const res = await fetch("https://api.lahsab.com/subscriptions", {
method: "POST",
headers: {
"x-api-key": process.env.LAHSAB_API_KEY,
"content-type": "application/json",
"Idempotency-Key": `sub-${user.id}-pro-mensuel`,
},
body: JSON.stringify({
customerId: customer.id,
priceId: PRO_MENSUEL,
daysUntilDue: 7,
}),
});
const subscription = await res.json();
// Un seul appel : l'abonnement existe, la 1re facture est émise.
window.location.href = subscription.latestInvoice.hostedInvoiceUrl; <?php
$ch = curl_init("https://api.lahsab.com/subscriptions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"x-api-key: " . getenv("LAHSAB_API_KEY"),
"content-type: application/json",
"Idempotency-Key: sub-" . $userId . "-pro-mensuel",
],
CURLOPT_POSTFIELDS => json_encode([
"customerId" => $customerId,
"priceId" => PRO_MENSUEL,
"daysUntilDue" => 7,
]),
]);
$subscription = json_decode(curl_exec($ch), true);
header("Location: " . $subscription["latestInvoice"]["hostedInvoiceUrl"]); import os, requests
res = requests.post(
"https://api.lahsab.com/subscriptions",
headers={
"x-api-key": os.environ["LAHSAB_API_KEY"],
"Idempotency-Key": f"sub-{user.id}-pro-mensuel",
},
json={
"customerId": customer.id,
"priceId": PRO_MENSUEL,
"daysUntilDue": 7,
},
)
subscription = res.json()
redirect(subscription["latestInvoice"]["hostedInvoiceUrl"]) الرد 201: الاشتراك مع فاتورته الأولى الصادرة سلفا:
{
"id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"environment": "live",
"status": "incomplete",
"collectionMethod": "send_invoice",
"daysUntilDue": 7,
"invoiceLeadDays": 7,
"currentPeriodStart": "2026-07-13T00:00:00.000Z",
"currentPeriodEnd": "2026-08-13T00:00:00.000Z",
"cancelAtPeriodEnd": false,
"latestInvoice": {
"id": "f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"number": "LSB-2026-0042",
"status": "open",
"amountDue": 2500,
"dueDate": "2026-07-20T00:00:00.000Z",
"hostedInvoiceUrl": "https://dashboard.lahsab.com/invoice/f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c"
},
"createdAt": "2026-07-13T10:00:00.000Z"
}
الحالات
الفاتورة
draft حالة عابرة: الفاتورة تعتمد (فترقم) مباشرة بعد إنشائها.
paid و void و uncollectible حالات نهائية.
| الحالة | المعنى |
|---|---|
draft | أنشئت، لم ترقم بعد وليست قابلة للدفع. |
open | معتمدة، مرقمة، قابلة للدفع. |
paid | دفعت. يصدر عندها حدث invoice.paid. |
void | ألغيتها أنت. |
uncollectible | استنفدت فترة السماح: لم تعد تتوقع تحصيلها. |
الاشتراك
يدخل الاشتراك من incomplete أو trialing، ويخرج من
unpaid أو canceled.
| الحالة | المعنى |
|---|---|
incomplete | صدرت الفاتورة الأولى ولم تدفع قط. لم يمنح أي وصول إطلاقا. |
trialing | تجربة جارية. currentPeriodEnd يساوي trialEnd. |
active | مدفوع إلى غاية currentPeriodEnd. |
past_due | انقضى تاريخ الاستحقاق وفاتورة التجديد ما تزال open. |
unpaid | استنفدت فترة السماح والفاتورة غير قابلة للتحصيل. |
canceled | انتهى. قيمة endedAt مسجلة. |
الترقيم
عند الاعتماد، تتلقى كل فاتورة رقما: LSB-{ANNÉE}-{séquence}.
LSB-2026-0001
LSB-2026-0002
LSB-2026-0003
التسلسل بلا فجوات، وخاص بكل ثلاثية (تاجر، بيئة، سنة). ويعود إلى 0001 في 1 جانفي.
المتأخرات
lahsab لا يذكّر زبونك مكانك. عندما ينقضي تاريخ الاستحقاق وتبقى الفاتورة open، يصدر حدث invoice.overdue: يتضمن payload الحدث الاسم والهاتف والمبلغ وتاريخ الاستحقاق ورابط الدفع، وهو ما يكفي لصياغة تذكيرك الخاص (رسالة أو مكالمة) دون نداء API إضافي. بعد ذلك، يتحكم إعدادان للتاجر، ضمن إعدادات لوحة التحكم، في المسار الموالي:
| الإعداد | القيمة الافتراضية | الدور |
|---|---|---|
gracePeriodDays | 7 | المهلة الممنوحة للزبون للدفع بعد تاريخ الاستحقاق. |
dunningExhaustedBehavior | cancel | مصير الاشتراك عند نهاية فترة السماح. |
عند نهاية فترة السماح، تتحول الفاتورة إلى uncollectible، ويصدر lahsab حدث invoice.marked_uncollectible، ويتبع الاشتراك الإعداد:
dunningExhaustedBehavior | عند نهاية فترة السماح |
|---|---|
cancel | يتحول الاشتراك إلى canceled. |
mark_unpaid | يتحول إلى unpaid، وتصبح الفاتورة uncollectible. |
leave_active | لا نمس شيئا، والتصرف بيدك. |
بوابة الزبون
يرجع POST /portal-sessions عنوان URL قصير الصلاحية (ساعة واحدة) يجد فيه زبونك اشتراكه وفواتيره وزر الدفع. إنها صفحة «تسيير اشتراكي» التي لن تحتاج إلى بنائها.
{
"url": "https://dashboard.lahsab.com/portal/ps_7f3c2e1a9b4d4e6a8c1f2d3e",
"expiresAt": "2026-07-13T11:00:00.000Z"
}