@mostajs/school-billing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +12 -0
- package/docs/00-PROPOSITION-PLAN-SCHOOL-BILLING-18062026.md +10 -0
- package/examples/frais/run.mjs +15 -0
- package/llms.txt +13 -0
- package/package.json +25 -0
- package/src/index.js +4 -0
- package/src/memory-repo.js +6 -0
- package/src/schemas.js +11 -0
- package/src/school-billing.js +70 -0
- package/test-scripts/unit/school-billing.test.mjs +43 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @mostajs/school-billing
|
|
2
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Statut** : 0.1.0 (6 tests verts)
|
|
3
|
+
> Frais scolaires + remises/promotions + factures/reçus + mensuel. Autonome ; compose payment/subscriptions-plan/numbering/file-export.
|
|
4
|
+
```js
|
|
5
|
+
import { createSchoolBilling, createMemoryRepositories } from '@mostajs/school-billing';
|
|
6
|
+
const b = createSchoolBilling({ repositories: createMemoryRepositories(), payment, numbering });
|
|
7
|
+
const f = await b.fees.define({ label:'Mensualité', kind:'mensuel', amount:6000 });
|
|
8
|
+
await b.discounts.define({ label:'Fratrie -15%', type:'percent', value:15, code:'FRATRIE' });
|
|
9
|
+
const inv = await b.invoices.create(studentId, { feeIds:[f.id], discountCode:'FRATRIE' });
|
|
10
|
+
await b.invoices.pay(inv.id, { method:'cib' });
|
|
11
|
+
```
|
|
12
|
+
Lancer : `node test-scripts/unit/school-billing.test.mjs && node examples/frais/run.mjs`
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @mostajs/school-billing — DEVRULES (§4 + #3 condensés)
|
|
2
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Date** : 2026-06-18 · **Statut** : cas C mince (P2 ATC F5).
|
|
3
|
+
## §4
|
|
4
|
+
Frais scolaires (inscription/mensuel/par niveau) + **remises/promotions** + factures/reçus + abonnement mensuel.
|
|
5
|
+
Le moteur paiement/récurrence existe (`payment`+`subscriptions-plan`) → school-billing = la **couche frais scolaires mince** qui les compose.
|
|
6
|
+
## §3 (API)
|
|
7
|
+
`createSchoolBilling({repositories,payment?,subscriptions?,numbering?,fileExport?})` → `fees.{define,list}`, `discounts.{define,list,byCode}`,
|
|
8
|
+
`invoices.{create(feeIds,discountCode),pay,listByStudent,outstanding,get,receipt}`, `subscribeMonthly(studentId,planId)`, `revenue({from,to})`.
|
|
9
|
+
**Compose (§10)** : payment (encaissement), subscriptions-plan (mensuel), numbering (n° reçu), file-export (reçu). Alimente reporting. Délègue l'élève à @mostajs/school.
|
|
10
|
+
## §4 (tests) : facture(somme), remise %/montant+promo, paiement(payment), outstanding+revenu, reçu(file-export), mensuel(subscriptions). (6 verts)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Exemple §12 — frais ATC (inscription + mensuel + remise fratrie). node examples/frais/run.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createSchoolBilling, createMemoryRepositories } from '../../src/index.js';
|
|
4
|
+
const b=createSchoolBilling({ repositories:createMemoryRepositories(),
|
|
5
|
+
numbering:{ next:()=> 'REC-2026-'+Math.floor(Math.random()*9999) },
|
|
6
|
+
payment:{ async charge(p){ return {ref:'CIB-'+Date.now()}; } },
|
|
7
|
+
fileExport:{ async exportDoc(fmt,doc){ return `(${fmt}) ${doc.title} = ${doc.items.at(-1).fields.montant}`; } } });
|
|
8
|
+
const insc=await b.fees.define({ label:"Frais d'inscription", kind:'inscription', amount:3000 });
|
|
9
|
+
const mens=await b.fees.define({ label:'Mensualité Anglais A1', kind:'mensuel', amount:6000 });
|
|
10
|
+
await b.discounts.define({ label:'Fratrie -15%', type:'percent', value:15, code:'FRATRIE' });
|
|
11
|
+
const inv=await b.invoices.create('eleve-ines',{ feeIds:[insc.id,mens.id], discountCode:'FRATRIE' });
|
|
12
|
+
await b.invoices.pay(inv.id,{ method:'cib' });
|
|
13
|
+
assert.equal(inv.subtotal,9000); assert.equal(inv.total,7650);
|
|
14
|
+
console.log('✅ school-billing — facture %s : %d - %d (fratrie) = %d DZD · payé · reçu: %s',
|
|
15
|
+
inv.receiptNo, inv.subtotal, inv.reduction, inv.total, await b.invoices.receipt(inv.id));
|
package/llms.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @mostajs/school-billing — fiche LLM
|
|
2
|
+
RÔLE
|
|
3
|
+
Frais scolaires : inscription/mensuel/par niveau + remises/promotions + factures/reçus + abonnement mensuel. Autonome/DB-agnostique.
|
|
4
|
+
Compose (injectés) : payment (encaissement), subscriptions-plan (mensuel), numbering (n° reçu), file-export (reçu). Alimente reporting. Délègue l'élève à @mostajs/school.
|
|
5
|
+
EXPORTS
|
|
6
|
+
createSchoolBilling({ repositories, payment?, subscriptionsPlan?, numbering?, fileExport?, now? }) -> { fees, discounts, invoices, subscribeMonthly, revenue }
|
|
7
|
+
createMemoryRepositories(); FeeSchema, DiscountSchema, InvoiceSchema
|
|
8
|
+
API
|
|
9
|
+
fees.define({label,kind:'inscription'|'mensuel'|'niveau',amount})/list ; discounts.define({label,type:'percent'|'amount',value,code})/list/byCode
|
|
10
|
+
invoices.create(studentId,{feeIds,discountCode|discountId})->{subtotal,reduction,total,receiptNo}/pay(id,{method})/listByStudent/outstanding/get/receipt(id,format)
|
|
11
|
+
subscribeMonthly(studentId,planId) [subscriptions-plan] ; revenue({from,to})
|
|
12
|
+
PIÈGES
|
|
13
|
+
- remise percent (×%) ou amount (montant fixe, plafonné au subtotal). pay compose payment.charge. mensuel = subscriptions-plan (scope 'student').
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostajs/school-billing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Frais scolaires : inscription/mensuel/par niveau, remises & promotions, factures/reçus, abonnement mensuel. DB-agnostique ; compose payment/subscriptions-plan/numbering/file-export. Alimente reporting.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./schemas": "./src/schemas.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mostajs",
|
|
15
|
+
"school-billing",
|
|
16
|
+
"fees",
|
|
17
|
+
"tuition",
|
|
18
|
+
"discounts",
|
|
19
|
+
"invoices"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node test-scripts/unit/school-billing.test.mjs",
|
|
23
|
+
"example": "node examples/frais/run.mjs"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// @mostajs/school-billing — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
export { createSchoolBilling } from './school-billing.js';
|
|
3
|
+
export { createMemoryRepositories } from './memory-repo.js';
|
|
4
|
+
export { FeeSchema, DiscountSchema, InvoiceSchema } from './schemas.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
function coll(){ const m=new Map(); return {
|
|
2
|
+
async create(d){ const id=d.id||globalThis.crypto.randomUUID(); const now=new Date(); const r={id,createdAt:now,updatedAt:now,...d}; m.set(id,r); return {...r}; },
|
|
3
|
+
async findById(id){ const r=m.get(id); return r?{...r}:null; },
|
|
4
|
+
async update(id,p){ const r=m.get(id); if(!r)return null; const x={...r,...p,updatedAt:new Date()}; m.set(id,x); return {...x}; },
|
|
5
|
+
async find(f=()=>true){ return [...m.values()].filter(f).map(r=>({...r})); } }; }
|
|
6
|
+
export function createMemoryRepositories(){ return { fees:coll(), discounts:coll(), invoices:coll() }; }
|
package/src/schemas.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @mostajs/school-billing — schémas. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
export const FeeSchema = { name:'Fee', collection:'fees', timestamps:true, fields:{
|
|
3
|
+
label:{type:'string',required:true}, kind:{type:'string',enum:['inscription','mensuel','niveau'],default:'inscription'},
|
|
4
|
+
amount:{type:'number',required:true}, currency:{type:'string',default:'DZD'} } };
|
|
5
|
+
export const DiscountSchema = { name:'Discount', collection:'discounts', timestamps:true, fields:{
|
|
6
|
+
label:{type:'string',required:true}, type:{type:'string',enum:['percent','amount'],default:'percent'}, value:{type:'number',required:true}, code:{type:'string'} } };
|
|
7
|
+
export const InvoiceSchema = { name:'Invoice', collection:'invoices', timestamps:true, fields:{
|
|
8
|
+
studentId:{type:'string',required:true}, items:{type:'array'}, subtotal:{type:'number'}, discountLabel:{type:'string'},
|
|
9
|
+
reduction:{type:'number',default:0}, total:{type:'number'}, currency:{type:'string',default:'DZD'},
|
|
10
|
+
status:{type:'string',enum:['pending','paid','cancelled'],default:'pending'}, receiptNo:{type:'string'}, paymentRef:{type:'string'}, at:{type:'date'}, paidAt:{type:'date'} },
|
|
11
|
+
indexes:[{fields:{studentId:'asc'}},{fields:{status:'asc'}}] };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @mostajs/school-billing — frais scolaires (mince). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Autonome/DB-agnostique. Compose (injectés) : payment (encaissement), subscriptions-plan (mensuel récurrent),
|
|
3
|
+
// numbering (n° reçu), file-export (reçu PDF). Délègue la scolarité à @mostajs/school ; alimente @mostajs/reporting.
|
|
4
|
+
|
|
5
|
+
export function createSchoolBilling({ repositories, payment, subscriptionsPlan, numbering, fileExport, now = () => new Date() } = {}) {
|
|
6
|
+
if (!repositories?.invoices) throw new Error('createSchoolBilling: repositories.invoices requis');
|
|
7
|
+
const { fees, discounts, invoices } = repositories;
|
|
8
|
+
const receiptNo = () => (numbering?.next ? numbering.next('school-receipt') : `REC-${now().getFullYear()}-${Math.floor(now().getTime() % 1000000)}`);
|
|
9
|
+
|
|
10
|
+
const applyDiscount = (subtotal, d) => {
|
|
11
|
+
if (!d) return { reduction: 0, total: subtotal };
|
|
12
|
+
const reduction = d.type === 'percent' ? Math.round(subtotal * (d.value / 100)) : Math.min(subtotal, d.value);
|
|
13
|
+
return { reduction, total: Math.max(0, subtotal - reduction) };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const api = {
|
|
17
|
+
fees: {
|
|
18
|
+
define: (dto) => fees.create({ kind: 'inscription', currency: 'DZD', ...dto }), // kind: inscription|mensuel|niveau
|
|
19
|
+
list: (f = {}) => fees.find((x) => Object.entries(f).every(([k, v]) => x[k] === v)),
|
|
20
|
+
},
|
|
21
|
+
discounts: {
|
|
22
|
+
define: (dto) => discounts.create({ type: 'percent', ...dto }), // type: percent|amount ; code? (promo)
|
|
23
|
+
list: () => discounts.find(),
|
|
24
|
+
byCode: async (code) => (await discounts.find((d) => d.code === code))[0] || null,
|
|
25
|
+
},
|
|
26
|
+
invoices: {
|
|
27
|
+
/** Crée une facture pour un élève à partir de frais + remise/promo éventuelle. */
|
|
28
|
+
async create(studentId, { feeIds = [], discountCode, discountId } = {}) {
|
|
29
|
+
const items = [];
|
|
30
|
+
for (const id of feeIds) { const f = await fees.findById(id); if (!f) throw new Error(`frais introuvable: ${id}`); items.push({ feeId: id, label: f.label, amount: f.amount }); }
|
|
31
|
+
const subtotal = items.reduce((s, i) => s + i.amount, 0);
|
|
32
|
+
const d = discountCode ? await api.discounts.byCode(discountCode) : (discountId ? await discounts.findById(discountId) : null);
|
|
33
|
+
const { reduction, total } = applyDiscount(subtotal, d);
|
|
34
|
+
return invoices.create({ studentId, items, subtotal, discountLabel: d?.label || null, reduction, total, currency: 'DZD', status: 'pending', receiptNo: receiptNo(), at: now() });
|
|
35
|
+
},
|
|
36
|
+
/** Encaissement (compose @mostajs/payment). */
|
|
37
|
+
async pay(invoiceId, { method } = {}) {
|
|
38
|
+
const inv = await invoices.findById(invoiceId); if (!inv) throw new Error('facture introuvable');
|
|
39
|
+
if (inv.status === 'paid') return inv;
|
|
40
|
+
let paymentRef = null;
|
|
41
|
+
if (payment?.charge) { const r = await payment.charge({ amount: inv.total, currency: inv.currency, method, purpose: 'school-fee', studentId: inv.studentId }); paymentRef = r?.ref ?? r?.id; }
|
|
42
|
+
return invoices.update(invoiceId, { status: 'paid', paymentRef, paidAt: now() });
|
|
43
|
+
},
|
|
44
|
+
listByStudent: (studentId) => invoices.find((i) => i.studentId === studentId),
|
|
45
|
+
async outstanding(studentId) { return (await invoices.find((i) => i.studentId === studentId && i.status !== 'paid')).reduce((s, i) => s + i.total, 0); },
|
|
46
|
+
get: (id) => invoices.findById(id),
|
|
47
|
+
async receipt(invoiceId, format = 'pdf') {
|
|
48
|
+
const inv = await invoices.findById(invoiceId); if (!inv) throw new Error('facture introuvable');
|
|
49
|
+
const doc = { title: `Reçu ${inv.receiptNo}`, items: [...inv.items.map((i) => ({ title: i.label, fields: { montant: i.amount } })),
|
|
50
|
+
...(inv.reduction ? [{ title: `Remise ${inv.discountLabel || ''}`, fields: { montant: -inv.reduction } }] : []),
|
|
51
|
+
{ title: 'TOTAL', fields: { montant: `${inv.total} ${inv.currency}` } }] };
|
|
52
|
+
return fileExport?.exportDoc ? fileExport.exportDoc(format, doc) : doc;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
/** Abonnement mensuel récurrent (compose @mostajs/subscriptions-plan). */
|
|
56
|
+
async subscribeMonthly(studentId, planId) {
|
|
57
|
+
if (!subscriptionsPlan?.subscribeToPlan) throw new Error('@mostajs/subscriptions-plan requis (mensuel)');
|
|
58
|
+
return subscriptionsPlan.subscribeToPlan({ accountId: studentId, planId, scope: { scopeType: 'student', scopeId: studentId } });
|
|
59
|
+
},
|
|
60
|
+
/** Revenu encaissé (alimente @mostajs/reporting). */
|
|
61
|
+
async revenue({ from, to } = {}) {
|
|
62
|
+
const paid = await invoices.find((i) => i.status === 'paid');
|
|
63
|
+
const inWin = (i) => (!from || new Date(i.paidAt || i.at) >= new Date(from)) && (!to || new Date(i.paidAt || i.at) <= new Date(to));
|
|
64
|
+
const w = paid.filter(inWin);
|
|
65
|
+
return { total: w.reduce((s, i) => s + i.total, 0), count: w.length };
|
|
66
|
+
},
|
|
67
|
+
repositories,
|
|
68
|
+
};
|
|
69
|
+
return api;
|
|
70
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createSchoolBilling, createMemoryRepositories } from '../../src/index.js';
|
|
3
|
+
let pass=0; const test=async(n,f)=>{await f();pass++;console.log(' ✓',n);};
|
|
4
|
+
const mk=(o={})=>createSchoolBilling({ repositories:createMemoryRepositories(), ...o });
|
|
5
|
+
|
|
6
|
+
await test('facture = somme des frais', async()=>{
|
|
7
|
+
const b=mk(); const f1=await b.fees.define({label:'Inscription',kind:'inscription',amount:2000});
|
|
8
|
+
const f2=await b.fees.define({label:'Mensuel juin',kind:'mensuel',amount:5000});
|
|
9
|
+
const inv=await b.invoices.create('e1',{feeIds:[f1.id,f2.id]});
|
|
10
|
+
assert.equal(inv.subtotal,7000); assert.equal(inv.total,7000); assert.ok(inv.receiptNo);
|
|
11
|
+
});
|
|
12
|
+
await test('remise % et montant + promo code', async()=>{
|
|
13
|
+
const b=mk(); const f=await b.fees.define({label:'Mensuel',amount:10000});
|
|
14
|
+
await b.discounts.define({label:'Fratrie -10%',type:'percent',value:10,code:'FRATRIE'});
|
|
15
|
+
const inv=await b.invoices.create('e1',{feeIds:[f.id],discountCode:'FRATRIE'});
|
|
16
|
+
assert.equal(inv.reduction,1000); assert.equal(inv.total,9000);
|
|
17
|
+
await b.discounts.define({label:'Bourse 3000',type:'amount',value:3000,code:'BOURSE'});
|
|
18
|
+
const inv2=await b.invoices.create('e2',{feeIds:[f.id],discountCode:'BOURSE'});
|
|
19
|
+
assert.equal(inv2.total,7000);
|
|
20
|
+
});
|
|
21
|
+
await test('paiement (compose payment) + statut', async()=>{
|
|
22
|
+
const charged=[]; const b=mk({ payment:{ async charge(p){charged.push(p);return {ref:'CIB-1'};} } });
|
|
23
|
+
const f=await b.fees.define({label:'X',amount:1000}); const inv=await b.invoices.create('e1',{feeIds:[f.id]});
|
|
24
|
+
await b.invoices.pay(inv.id,{method:'cib'});
|
|
25
|
+
assert.equal((await b.invoices.get(inv.id)).status,'paid'); assert.equal(charged[0].purpose,'school-fee');
|
|
26
|
+
});
|
|
27
|
+
await test('encours (outstanding) + revenu', async()=>{
|
|
28
|
+
const b=mk(); const f=await b.fees.define({label:'X',amount:1000});
|
|
29
|
+
const a=await b.invoices.create('e1',{feeIds:[f.id]}); await b.invoices.create('e1',{feeIds:[f.id]});
|
|
30
|
+
assert.equal(await b.invoices.outstanding('e1'),2000);
|
|
31
|
+
await b.invoices.pay(a.id); assert.equal(await b.invoices.outstanding('e1'),1000);
|
|
32
|
+
assert.equal((await b.revenue()).total,1000);
|
|
33
|
+
});
|
|
34
|
+
await test('reçu (compose file-export)', async()=>{
|
|
35
|
+
const exp=[]; const b=mk({ fileExport:{ async exportDoc(fmt,doc){exp.push(fmt);return 'recu.pdf';} } });
|
|
36
|
+
const f=await b.fees.define({label:'X',amount:500}); const inv=await b.invoices.create('e1',{feeIds:[f.id]});
|
|
37
|
+
assert.equal(await b.invoices.receipt(inv.id),'recu.pdf'); assert.equal(exp[0],'pdf');
|
|
38
|
+
});
|
|
39
|
+
await test('mensuel : compose subscriptions-plan', async()=>{
|
|
40
|
+
const subs=[]; const b=mk({ subscriptionsPlan:{ async subscribeToPlan(p){subs.push(p);return {id:'sub1'};} } });
|
|
41
|
+
await b.subscribeMonthly('e1','plan-mensuel'); assert.equal(subs[0].planId,'plan-mensuel'); assert.equal(subs[0].scope.scopeId,'e1');
|
|
42
|
+
});
|
|
43
|
+
console.log(`\n✅ @mostajs/school-billing — ${pass} tests OK`);
|