Calendar Matrix — Rating, Billing, Invoicing + Standard Calendar Definitions
The 3 Calendar Concepts (Renamed for Clarity)
Per Charge Type
RECURRING Charge
| V1 API Field | Proposed Name | Entity Field (current) | Purpose | Mandatory | Note |
|---|---|---|---|---|---|
chargeApplicationCalendar | Charge Application Calendar | RecurringChargeTemplate.calendar | HOW OFTEN the charge fires. Defines period boundaries (startDate/endDate on WO). | Yes | Existing field, renamed from billingCalendar |
| — | ratingPeriodCalendar | — | N/A for recurring | No | Not applicable — recurring charges don't accumulate EDRs |
invoicingCalendar | Invoicing Calendar | ChargeInstance.invoicingCalendar (runtime) | WHEN the WO becomes eligible for a bill run. Sets WO.invoicingDate. | Yes (new!) | Currently optional, inherited from ServiceInstance. Flag: "Same as chargeApplicationCalendar" = default. Uncheck to set different calendar. |
Sidebar: RecurringChargeTemplate.calendar is the existing JPA field. The V1 API currently exposes it as billingCalendar. Proposal: rename API field to chargeApplicationCalendar.
USAGE Charge
| V1 API Field | Proposed Name | Entity Field (current) | Purpose | Mandatory | Note |
|---|---|---|---|---|---|
| — | chargeApplicationCalendar | — | N/A for usage | No | Usage charges fire per EDR, not per calendar period |
ratingPeriodCalendar | Rating Period Calendar | CounterTemplate.calendar (linked via product) | WHEN usage counters reset. Defines accumulation window for tiered/volume/package pricing. | Required if TIERED/VOLUME/PACKAGE | Not on ChargeTemplate directly — lives on CounterTemplate, linked via ProductChargeTemplateMapping → CounterTemplate |
invoicingCalendar | Invoicing Calendar | ChargeInstance.invoicingCalendar (runtime) | WHEN the WO becomes eligible for a bill run. | Yes (new!) | Flag: "Same as ratingPeriodCalendar" = default. Uncheck to set different calendar. If no ratingPeriodCalendar, must be set explicitly. |
Sidebar: CounterTemplate.calendar is the actual entity field for rating period. It's NOT on UsageChargeTemplate directly — it's on the counter that's linked to the charge via the product. The V1 API currently accepts ratingCalendar.calendar in the charge payload, but internally it maps to creating/updating a CounterTemplate.
ONE-SHOT Charge
| V1 API Field | Proposed Name | Entity Field | Purpose | Mandatory | Note |
|---|---|---|---|---|---|
| — | chargeApplicationCalendar | — | N/A | — | Fires once on event |
| — | ratingPeriodCalendar | — | N/A | — | No accumulation |
invoicingCalendar | Invoicing Calendar | ChargeInstance.invoicingCalendar | Optional gate. If set, delays billing eligibility. | No | Usually null → immediately eligible. Use case: "bill penalty only at next billing cycle" |
Summary Matrix
chargeApplication ratingPeriod invoicing
Calendar Calendar Calendar
───────────────── ──────────────── ─────────────────
RECURRING ✅ MANDATORY ❌ N/A ✅ MANDATORY (new)
When charge fires No accumulation Flag: = chargeApp
Entity: ChargeTemplate.calendar Entity: ChargeInstance
USAGE ❌ N/A ✅ IF tiered/vol ✅ MANDATORY (new)
Fires per EDR Counter reset Flag: = ratingPeriod
Entity: CounterTemplate.calendar
ONE-SHOT ❌ N/A ❌ N/A ⚪ OPTIONAL
Fires once No accumulation Gate for deferred billing
Per Charge Type Matrix
RECURRING Charges
| Calendar | Used? | What it does | Example |
|---|---|---|---|
| billingCalendar | YES (mandatory) | Defines charge period. Calendar boundaries → WO.startDate/endDate. Determines when charge fires. | MONTHLY → charge fires Jan 1, Feb 1, Mar 1... |
| ratingCalendar | NO | Not applicable — recurring charges don't accumulate EDRs | — |
| invoicingCalendar | Optional | If set: WO.invoicingDate = calendar.nextDate(operationDate). If null: invoicingDate = null → immediately eligible for billing | MONTHLY → WO created Jan 1 gets invoicingDate = Feb 1, meaning it won't appear on January invoice but will on February's |
Impact on rating:
RecurringRatingJob runs with rateUntilDate = Feb 1
ChargeInstance:
billingCalendar = MONTHLY (1st of month)
chargeDate = Jan 1
nextChargeDate = Feb 1 (from calendar.nextCalendarDate)
Since nextChargeDate <= rateUntilDate:
→ Create WO:
operationDate = Jan 1 (applyInAdvance=true)
startDate = Jan 1 (calendar.previousCalendarDate)
endDate = Feb 1 (calendar.nextCalendarDate)
invoicingDate = null (no invoicingCalendar)
amount = 150 MAD
Impact on billing:
Bill run with lastTransactionDate = Feb 1:
SELECT RT WHERE status='OPEN'
AND usageDate >= Jan 1 AND usageDate < Feb 1
AND (invoicingDate IS NULL OR invoicingDate < Feb 1)
→ RT with usageDate=Jan 1, invoicingDate=null → SELECTED ✓
USAGE Charges
| Calendar | Used? | What it does | Example |
|---|---|---|---|
| billingCalendar | Optional | If set on charge: defines the invoicing period for usage. Currently mapped to invoicingCalendar on ChargeInstance at runtime. | MONTHLY → usage accumulated during January is invoiced in February |
| ratingCalendar | YES (key for tiered/volume) | Defines counter reset window. When period boundary is crossed, accumulated quantity resets to 0. | WEEKLY → data tier counters reset every Monday. "5 GB free per week" |
| invoicingCalendar | Optional | Same as recurring: sets WO.invoicingDate as billing gate | See above |
Impact on rating:
UsageRatingJob processes EDR:
EDR: eventDate=Jan 15, quantity=3000 MB, parameter1=DATA
1. Find matching UsageChargeInstance (filterParam1=DATA)
2. Check CounterPeriod (ratingCalendar = WEEKLY):
Current period: Jan 13 → Jan 20
Accumulated so far: 4000 MB
3. Accumulate: 4000 + 3000 = 7000 MB
4. Apply tiered pricing:
Tier 1: 0-5120 MB → free (1120 MB remaining in tier)
Tier 2: 5120+ MB → 0.10 MAD/MB (1880 MB in this tier)
amount = 1880 * 0.10 = 188 MAD
5. Create WO:
operationDate = Jan 15 (EDR.eventDate)
startDate = NULL (usage has no period)
endDate = NULL
invoicingDate = null (no invoicingCalendar)
amount = 188 MAD
6. Jan 20 (WEEKLY boundary): counter RESETS to 0
Next EDR starts fresh in Tier 1
Impact on billing:
Bill run with lastTransactionDate = Feb 1:
SELECT RT WHERE usageDate >= Jan 1 AND usageDate < Feb 1
→ All January usage RTs selected
ONE-SHOT Charges
| Calendar | Used? | What it does | Example |
|---|---|---|---|
| billingCalendar | NO | Not applicable — one-shot fires once | — |
| ratingCalendar | NO | Not applicable — no accumulation | — |
| invoicingCalendar | Optional | Same gate mechanism | Usually null → immediately eligible |
Your 3-Charge Scenario
Subscription starts: Jan 5
CHARGE 1: Recurring monthly at anniversary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
billingCalendar = CalendarPeriod(MONTH, initDate=Jan 5) ← JOIN/anniversary
ratingCalendar = n/a
invoicingCalendar = null (immediately eligible)
Timeline:
Jan 5 Feb 5 Mar 5
├────────────┤────────────┤
│ Period 1 │ Period 2 │
│ WO: 150MAD │ WO: 150MAD│
│ inv: null │ inv: null │
Any bill run after Jan 5 can pick up Period 1
CHARGE 2: Usage formula, rated per EDR, billed daily
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
billingCalendar = n/a (no charge period for usage)
ratingCalendar = null (formula pricing, no accumulation needed)
invoicingCalendar = CalendarFixed(DAILY) ← "eligible next day"
Timeline:
Jan 15 EDR → WO rated immediately (formula) → invoicingDate = Jan 16
Jan 15 EDR → WO rated immediately → invoicingDate = Jan 16
Jan 16 EDR → WO rated immediately → invoicingDate = Jan 17
Daily bill run on Jan 16:
Selects all RTs where invoicingDate < Jan 16
→ Jan 15 WOs (invoicingDate=Jan 16 is NOT < Jan 16) ← EXCLUDED!
Hmm, this depends on the bill run's invoiceUpToDate calculation.
Alternative: invoicingCalendar = null, bill run daily with
lastTransactionDate = yesterday.
CHARGE 3: Usage rated weekly (tiered), billed end of month
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
billingCalendar = n/a
ratingCalendar = CalendarFixed(WEEKLY) ← counters reset weekly
invoicingCalendar = CalendarFixed(MONTHLY, 1st of month) ← eligible at month end
Timeline:
Week 1 (Jan 1-7): 6GB → 1GB overage = 100 MAD
WO.invoicingDate = Feb 1
── counter resets ──
Week 2 (Jan 8-14): 3GB → no overage = 0 MAD
── counter resets ──
Week 3 (Jan 15-21): 7GB → 2GB overage = 200 MAD
WO.invoicingDate = Feb 1
── counter resets ──
Week 4 (Jan 22-28): 4GB → no overage = 0 MAD
Monthly bill run on Feb 1 (lastTransactionDate = Feb 1):
Selects all RTs where invoicingDate < Feb 1
→ Jan WOs with invoicingDate = Feb 1... NOT < Feb 1 → EXCLUDED again!
This shows the invoicingDate gate is <= or < sensitive.
Need to check: is it < or <=?
Current Gap: invoicingCalendar Not in V1 API
Today's state:
billingCalendar→ V1 API ✅ (for recurring)ratingCalendar→ V1 API ✅ (for usage, accumulator config)invoicingCalendar→ NOT in V1 API ❌
To support the 3-charge scenario, we need to add:
invoicingCalendarfield toChargeV1Dto- Map it in
ChargeV1Mapper(read fromChargeInstance.invoicingCalendar) - Persist it: either on
ChargeTemplate(catalog level, preferred) or keep onServiceInstance - Expose in UI ChargeModal
Key Insight: billingCalendar vs invoicingCalendar for Usage
For USAGE charges, the current V1 API field billingCalendar is ambiguous:
- In
RecurringChargeTemplate,calendar= charge period (when it fires) - For USAGE, there's no "charge period" — EDRs fire when they arrive
- So what does
billingCalendarmean for usage? → It maps toinvoicingCalendarat runtime
Proposal for V1 API clarity:
| V1 API field | RECURRING meaning | USAGE meaning |
|---|---|---|
billingCalendar | Charge period (when it fires) | invoicingCalendar (when eligible for billing) |
ratingCalendar.calendar | n/a | Counter reset calendar |
invoicingCalendar (NEW) | When WO eligible for billing | When WO eligible for billing |
For recurring charges, if invoicingCalendar is null, the WO is immediately eligible. The billingCalendar only defines the charge period, NOT the invoicing gate.
Standard Calendar Definitions (OOTB)
Calendar Types in the Engine
| Type | Java Class | Behavior | Init date used? |
|---|---|---|---|
YEARLY | CalendarYearly | Fixed monthly boundaries (1st of each month) | NO — boundaries are absolute |
PERIOD | CalendarPeriod | Repeating period (N days/months/hours) from init date | YES — period starts from init date (anniversary) |
JOIN | CalendarJoin | Combines two calendars (UNION, INTERSECT, APPEND) | YES — propagated to children |
BANKING | CalendarBanking | Banking holidays calendar | NO — fixed dates |
FIXED | CalendarFixed | Explicit date pairs (ad-hoc periods) | NO — explicit boundaries |
Period Units (CalendarPeriodUnitEnum)
| Unit | Java constant | Example |
|---|---|---|
MONTH | Calendar.MONTH | 1 month, 3 months (quarterly), 6 months |
DAY_OF_MONTH | Calendar.DAY_OF_MONTH | 1 day, 7 days (weekly), 14 days |
HOUR_OF_DAY | Calendar.HOUR_OF_DAY | 1 hour, 24 hours |
MINUTE | Calendar.MINUTE | 15 minutes, 60 minutes |
SECOND | Calendar.SECOND | For real-time billing |
Proposed Standard Calendar Codes (Naming Convention)
Convention: CAL_{FREQUENCY}_{BOUNDARY}
| Code | Type | Period | Boundary | Use Case |
|---|---|---|---|---|
CAL_MONTHLY | YEARLY | 1 month | 1st of each month | Standard billing cycle, counter reset |
CAL_MONTHLY_ANNIV | PERIOD(MONTH,1) | 1 month | Anniversary of init date | Anniversary billing (sub on 15th → 15th each month) |
CAL_QUARTERLY | YEARLY | 3 months | Jan/Apr/Jul/Oct 1st | Quarterly billing |
CAL_QUARTERLY_ANNIV | PERIOD(MONTH,3) | 3 months | Every 3 months from init | Anniversary quarterly |
CAL_SEMI_ANNUAL | YEARLY | 6 months | Jan/Jul 1st | Semi-annual billing |
CAL_ANNUAL | YEARLY | 12 months | Jan 1st | Annual billing |
CAL_ANNUAL_ANNIV | PERIOD(MONTH,12) | 12 months | Anniversary of init | Annual anniversary |
CAL_WEEKLY | PERIOD(DAY,7) | 7 days | Every 7 days from init | Weekly counter reset |
CAL_WEEKLY_MON | YEARLY | 7 days | Every Monday | Fixed weekly (Mon-Sun) |
CAL_DAILY | PERIOD(DAY,1) | 1 day | Every day from init | Daily billing/reset |
CAL_DAILY_MIDNIGHT | YEARLY | 1 day | Every midnight | Fixed daily (00:00-23:59) |
CAL_HOURLY | PERIOD(HOUR,1) | 1 hour | Every hour | Real-time usage rating |
Key Difference: YEARLY vs PERIOD for "Monthly"
CAL_MONTHLY (YEARLY type):
Always 1st of month → 1st of next month
Sub on Jan 15 → period is still Jan 1 → Feb 1
No relationship to subscription date
✅ Use for: standard billing cycles, invoicing gates
CAL_MONTHLY_ANNIV (PERIOD type, unit=MONTH, length=1):
Anchored to init date (subscription date)
Sub on Jan 15 → period is Jan 15 → Feb 15
⚠️ Edge case: Jan 31 → Feb 28 → Mar 28 (drifts!)
✅ Use for: anniversary billing, commitment periods
Which Calendar for Each Purpose?
| Purpose | Recommended Calendar | Why |
|---|---|---|
| Recurring billing (standard) | CAL_MONTHLY | Predictable boundaries, no drift |
| Recurring billing (anniversary) | CAL_MONTHLY_ANNIV | Tied to customer's start date |
| Usage counter reset (monthly) | CAL_MONTHLY | Reset on 1st, predictable for customers |
| Usage counter reset (weekly) | CAL_WEEKLY_MON | Reset every Monday, clear boundaries |
| Invoicing gate (end of month) | CAL_MONTHLY | WO eligible on 1st of next month |
| Invoicing gate (daily) | CAL_DAILY_MIDNIGHT | WO eligible next day |
| Commitment period (12 months) | CAL_ANNUAL_ANNIV | Exactly 12 months from subscription |
Your 3-Charge Scenario with New Naming + Standard Calendars
Subscription start: Jan 5
CHARGE 1: Recurring monthly at anniversary
chargeApplicationCalendar = CAL_MONTHLY_PERIODIC ← fires Jan 5, Feb 5, Mar 5
ratingPeriodCalendar = n/a ← not applicable for recurring
invoicingCalendar = ☑ Same as chargeApplicationCalendar
= CAL_MONTHLY_PERIODIC ← eligible at period end
startDateStrategy = PRODUCT_ACTIVATION_DATE
→ WO created Jan 5, invoicingDate = Feb 5
→ Bill run after Feb 5 picks it up
CHARGE 2: Usage formula, rated per EDR, billed daily
chargeApplicationCalendar = n/a ← fires per EDR
ratingPeriodCalendar = null ← no accumulation for formula
invoicingCalendar = CAL_DAILY ← eligible next midnight
startDateStrategy = n/a (no periodic cal)
→ EDR Jan 15 14:00 → WO rated → invoicingDate = Jan 16 00:00
→ Daily bill run on Jan 16 picks it up
CHARGE 3: Usage tiered, counters reset weekly, billed monthly
chargeApplicationCalendar = n/a ← fires per EDR
ratingPeriodCalendar = CAL_1ST_WEEK_MON ← counters reset every Monday
invoicingCalendar = ☑ Same as ratingPeriodCalendar? NO → override:
= CAL_1ST_MONTH ← eligible 1st of next month
startDateStrategy = CALENDAR_START (fixed Monday boundary, no init needed)
→ Week 1: 6GB → 1GB overage = 100 MAD, invoicingDate = Feb 1
→ Week 2 counter resets, 3GB → free
→ Week 3: 7GB → 2GB overage = 200 MAD, invoicingDate = Feb 1
→ Monthly bill run on Feb 1 picks up all January WOs
Why ratingPeriodCalendar is NOT on ChargeTemplate (and why API should abstract this)
Current entity model:
UsageChargeTemplate ──(no FK to CounterTemplate)──╮
│
ProductChargeTemplateMapping ──────────────────────┤
├── chargeTemplate (FK to ChargeTemplate) │
├── counterTemplate (FK to CounterTemplate) ◄────╯ ← HERE
└── accumulatorCounterTemplates (M2M)
CounterTemplate holds: calendar, accumulatorType, keyEl, valueEl, filterEl, ceiling
Why this matters for API design:
- From the API user's perspective, they configure accumulation ON the charge
- Internally, a
CounterTemplateis auto-created with codeACC_{type}_{trackedValue} - The link goes through
ProductChargeTemplateMapping.counterTemplate(not on charge) - The V1 API already abstracts this:
ratingCalendar.accumulator.*← user doesn't see CounterTemplate
Impact on schema: No entity changes needed for the API. The V1 API should continue to accept ratingPeriodCalendar + accumulator on the charge payload, and internally create/link the CounterTemplate. This is the current behavior (with TODO for incomplete field mapping).
Impact on UI: The ChargeModal already has the accumulator fields. They just need to be properly wired to save via V1 API.
Why CounterTemplate is on ProductChargeTemplateMapping and NOT on ChargeTemplate?
Historical reason: In the old ServiceTemplate model, a charge could be shared across multiple services with DIFFERENT counters. The counter was per "usage in a service", not per "charge template". This is a legacy design decision.
Problem this creates: If you define a charge WITHOUT a parent product (standalone charge), you can't attach a counter/ratingPeriodCalendar.
Proposed fix: Add ratingPeriodCalendar (calendar code) and accumulator fields directly on UsageChargeTemplate entity. This makes the charge self-contained. The ProductChargeTemplateMapping.counterTemplate becomes a runtime override (optional, for product-specific counter configuration).
Entity change:
// UsageChargeTemplate.java — NEW fields
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "rating_calendar_id")
private Calendar ratingPeriodCalendar;
@Column(name = "accumulator_type")
@Enumerated(EnumType.STRING)
private AccumulatorCounterTypeEnum accumulatorType;
@Column(name = "accumulator_tracked_value")
private String accumulatorTrackedValue; // QUANTITY, AMOUNT, AMOUNT_WITH_TAX
@Column(name = "accumulator_ceiling")
private BigDecimal accumulatorCeiling;
@Column(name = "accumulator_key_el")
private String accumulatorKeyEl;
@Column(name = "accumulator_value_el")
private String accumulatorValueEl;
@Column(name = "accumulator_filter_el")
private String accumulatorFilterEl;
At runtime, if ProductChargeTemplateMapping.counterTemplate is set, it overrides these defaults.
V1 API Payloads (Target State)
RECURRING Charge — Create/Update
POST /api/v1/recurringCharges/CHG_NFLX_STD
{
"code": "CHG_NFLX_STD",
"description": "Netflix Standard - Mensuel",
"invoiceSubCategory": "ISUB_STREAM_BASE",
"taxClass": "TAX_MA_20",
"chargeApplicationCalendar": "CAL_MONTHLY_PERIODIC",
"startDateStrategy": "PRODUCT_ACTIVATION_DATE",
"invoicingCalendar": "CAL_MONTHLY_PERIODIC",
"invoicingCalendarSameAsChargeApplication": true,
"applyInAdvance": true,
"subscriptionProrata": true,
"terminationProrata": true,
"articleSimilarToCharge": true,
"amountEditable": true
}
USAGE Charge — Create/Update
POST /api/v1/usageCharges/CHG_INWI_DATA
{
"code": "CHG_INWI_DATA",
"description": "Data usage - overage after 5GB",
"invoiceSubCategory": "ISUB_TEL_DATA",
"taxClass": "TAX_MA_20",
"inputUnitOfMeasure": "MB",
"ratingUnitOfMeasure": "MB",
"ratingPeriodCalendar": "CAL_1ST_MONTH",
"startDateStrategy": "CALENDAR_START",
"accumulator": {
"type": "SINGLE_VALUE",
"trackedValue": "QUANTITY",
"initialValue": null,
"dimensions": null
},
"invoicingCalendar": "CAL_1ST_MONTH",
"invoicingCalendarSameAsRatingPeriod": true,
"filterParam1": "DATA",
"priority": 1,
"triggerNextCharge": false,
"articleSimilarToCharge": true
}
USAGE Charge — Multi-Value Accumulator Example
POST /api/v1/usageCharges/CHG_API_TOKENS
{
"code": "CHG_API_TOKENS",
"description": "API Token usage per model",
"ratingPeriodCalendar": "CAL_1ST_MONTH",
"accumulator": {
"type": "MULTI_VALUE",
"trackedValue": "QUANTITY",
"initialValue": 1000000,
"dimensions": {
"key_fx": "#{edr.parameter1}",
"value_fx": "#{edr.quantity}",
"filter_fx": "#{edr.parameter2 == 'TOKEN'}"
}
},
"invoicingCalendar": "CAL_1ST_MONTH",
"invoicingCalendarSameAsRatingPeriod": true
}
ONE-SHOT Charge — Create/Update
POST /api/v1/oneShotCharges/CHG_ORG_SETUP
{
"code": "CHG_ORG_SETUP",
"description": "Frais de mise en service",
"invoiceSubCategory": "ISUB_SETUP",
"taxClass": "TAX_MA_20",
"oneShotType": "SUBSCRIPTION",
"immediateInvoicing": true,
"invoicingCalendar": null,
"articleSimilarToCharge": true,
"amountEditable": true
}
UI Fields per Charge Type (Target State)
General Tab (ALL charge types)
| Field | API Name | Type | Mandatory | Source |
|---|---|---|---|---|
| Code | code | text (readonly edit) | Yes | — |
| Name | description | text | Yes | — |
| Invoice Sub-Category | invoiceSubCategory | autocomplete | Yes | /api/v1/invoiceSubCategories |
| Tax Class | taxClass | autocomplete | Yes | /api/v1/taxClasses |
| Unit of Measure | inputUnitOfMeasure | autocomplete | No | /api/v1/unitOfMeasures |
| Auto-create Article | articleSimilarToCharge | checkbox | No | Default: true |
Recurring Settings Tab (RECURRING only)
| Field | API Name | Type | Mandatory | Source |
|---|---|---|---|---|
| Charge Application Calendar | chargeApplicationCalendar | autocomplete | Yes | /api/v1/calendars |
| Start Date Strategy | startDateStrategy | select | No | PRODUCT_ACTIVATION_DATE (default), SUBSCRIPTION_START, CALENDAR_START |
| Invoicing Calendar | invoicingCalendar | autocomplete | Yes | /api/v1/calendars |
| ☑ Same as Charge Application | invoicingCalendarSameAsChargeApplication | checkbox | — | Default: true. Hides calendar field when checked. |
| Apply In Advance | applyInAdvance | toggle | No | Default: true |
| Prorate on Subscription | subscriptionProrata | toggle | No | Default: true |
| Prorate on Termination | terminationProrata | toggle | No | Default: true |
Usage Settings Tab (USAGE only)
Section: Rating Period
| Field | API Name | Type | Mandatory | Source |
|---|---|---|---|---|
| Rating Period Calendar | ratingPeriodCalendar | autocomplete | If tiered/volume | /api/v1/calendars |
| Start Date Strategy | startDateStrategy | select | No | CALENDAR_START (default), PRODUCT_ACTIVATION_DATE, SUBSCRIPTION_START, FIRST_EVENT |
| Accumulator Type | accumulator.type | select | If rating cal set | SINGLE_VALUE, MULTI_VALUE |
| Tracked Value | accumulator.trackedValue | select | If accum type set | QUANTITY (default), AMOUNT, AMOUNT_WITH_TAX |
| Ceiling | accumulator.ceiling | number | No | Max accumulated value |
Section: Accumulator Dimensions (if MULTI_VALUE)
| Field | API Name | Type | Mandatory |
|---|---|---|---|
| Key Expression (EL) | accumulator.dimensions.key_fx | formula | If MULTI_VALUE |
| Value Expression (EL) | accumulator.dimensions.value_fx | formula | If MULTI_VALUE |
| Filter Expression (EL) | accumulator.dimensions.filter_fx | formula | No |
Section: Invoicing
| Field | API Name | Type | Mandatory | Source |
|---|---|---|---|---|
| Invoicing Calendar | invoicingCalendar | autocomplete | Yes | /api/v1/calendars |
| ☑ Same as Rating Period | invoicingCalendarSameAsRatingPeriod | checkbox | — | Default: true. Hides calendar field when checked. |
Section: EDR Routing
| Field | API Name | Type | Mandatory |
|---|---|---|---|
| Route Parameter 1 | filterParam1 | text | No |
| Route Parameter 2-4 | filterParam2-4 | text | No |
| Priority | priority | number | No |
| Cascade to Next | triggerNextCharge | toggle | No |
One-Time Settings Tab (ONE-SHOT only)
| Field | API Name | Type | Mandatory |
|---|---|---|---|
| Sub-Type | oneShotType | select | Yes (SUBSCRIPTION, TERMINATION, OTHER) |
| Immediate Invoicing | immediateInvoicing | toggle | No (default: true) |
| Invoicing Calendar | invoicingCalendar | autocomplete | No (optional gate) |
Advanced Tab (ALL charge types)
| Field | API Name | Type | Mandatory |
|---|---|---|---|
| Allow Price Override | amountEditable | toggle | No (default: true) |
| Rounding Mode | roundingMode | select | No |
| Application Filter (EL) | filterExpression | formula | No |
Deep Dive: Counters, Accumulators, and Multi-Value
CounterInstance → linked to ServiceInstance (or subscription/account level)
CounterTemplate (catalog)
→ CounterInstance (runtime, per ServiceInstance or per Subscription)
→ CounterPeriod (per calendar period, holds accumulated values)
CounterInstance is NOT linked to ChargeInstance directly. It's linked to one of:
- ServiceInstance (most common for usage charges)
- Subscription
- BillingAccount / CustomerAccount / Customer (shared counters)
SINGLE_VALUE vs MULTI_VALUE Accumulators
SINGLE_VALUE: One cumulative number per period
counterType: USAGE → tracks walletOperation.quantity
counterType: USAGE_AMOUNT → tracks walletOperation.amountWithoutTax
Example: "5 GB included per month"
Period Jan: value starts at 0
EDR 1 (+1000 MB): value = 1000
EDR 2 (+2000 MB): value = 3000
EDR 3 (+3000 MB): value = 6000 → exceeded 5120 MB → overage billed
MULTI_VALUE: Cumulative number per dimension KEY per period
keyEl: "#{op.parameter1}" → extracts dimension key from WalletOperation
valueEl: "#{op.quantity}" → extracts value to accumulate
filterEl: "#{op.quantity > 0}" → optional filter
Example: "Track usage per API model"
Period Jan:
WO(param1="gpt-4", qty=1000): → accumulatedValues["gpt-4"] = 1000
WO(param1="gpt-3.5", qty=5000): → accumulatedValues["gpt-3.5"] = 5000
WO(param1="gpt-4", qty=2000): → accumulatedValues["gpt-4"] = 3000
Result: { "gpt-4": 3000, "gpt-3.5": 5000 }
EL Context Variables
In keyEl/valueEl/filterEl, the WalletOperation is available as op:
#{op.quantity} → BigDecimal (rated quantity)
#{op.amountWithoutTax} → BigDecimal (rated amount excl. tax)
#{op.amountWithTax} → BigDecimal (rated amount incl. tax)
#{op.parameter1} → String (EDR param1)
#{op.parameter2} → String (EDR param2)
#{op.parameter3} → String (EDR param3)
#{op.parameterExtra} → String
#{op.startDate} → Date
#{op.endDate} → Date
#{op.getCountryCode()} → String (if available)
What ceiling does
ceiling is the initial counter level (NOT a cap):
- For "5 GB included": ceiling = 5120 (MB)
- Counter starts at ceiling value and DECREASES as usage is consumed
- When counter reaches 0, included quota is exhausted → overage starts
ceilingExpressionElallows dynamic ceiling (e.g., based on subscription attribute)
dimensions should be an array?
Current model: Single key/value pair (one keyEl + one valueEl).
Limitation: Can't track multiple independent dimensions (e.g., country AND model simultaneously).
Each dimension generates a different key string in the accumulatedValues map.
For composite dimensions, use concatenated key:
keyEl: "#{op.parameter1}_#{op.parameter2}" → "US_gpt-4", "FR_gpt-3.5"
If true multi-dimensional tracking is needed (array of dimensions), that would require entity model changes.
trackedValue should be trackedValue_fx?
Current model uses counterType enum (not EL):
USAGE→ trackswalletOperation.quantityUSAGE_AMOUNT→ trackswalletOperation.amountWithoutTax/Tax
But valueEl IS an EL expression that can track anything. So for the V1 API:
trackedValue=QUANTITYmaps to counterType=USAGE (uses quantity)trackedValue=AMOUNTmaps to counterType=USAGE_AMOUNT (uses amount)trackedValue=CUSTOM→ useaccumulator.valueEl(any EL expression)
UnitOfMeasure — on ALL charge types
inputUnitOfMeasure and ratingUnitOfMeasure are on the base ChargeTemplate class (all types inherit):
- RECURRING: can use UoM (e.g., "per seat", "per license")
- USAGE: commonly used (MB→GB, seconds→minutes, tokens→K tokens)
- ONE-SHOT: rarely used but available
Sample Data Walkthrough: Complete EDR → Invoice
Setup
Subscription: SUB_KARIM
Product: PROD_INWI_IDAR
Started: 2026-01-05
Charge 1: CHG_INWI_BASE (RECURRING)
chargeApplicationCalendar: CAL_MONTHLY_PERIODIC (init=Jan 5)
invoicingCalendar: same (CAL_MONTHLY_PERIODIC)
price: 49.00 MAD (FLAT)
applyInAdvance: true
Charge 2: CHG_INWI_DATA (USAGE, TIERED)
ratingPeriodCalendar: CAL_1ST_MONTH (counter resets 1st of month)
invoicingCalendar: CAL_1ST_MONTH
accumulator: SINGLE_VALUE, trackedValue=QUANTITY, ceiling=5120 (MB)
tiers: [0-5120: 0.00, 5120+: 0.10 MAD/MB]
filterParam1: DATA
startDateStrategy: CALENDAR_START
Charge 3: CHG_INWI_VOICE (USAGE, TIERED)
ratingPeriodCalendar: CAL_1ST_MONTH
invoicingCalendar: CAL_1ST_MONTH
accumulator: SINGLE_VALUE, trackedValue=QUANTITY, ceiling=300 (min)
tiers: [0-300: 0.00, 300+: 0.50 MAD/min]
filterParam1: VOICE
Step 1: RecurringRatingJob runs (rateUntilDate=Feb 5)
CHG_INWI_BASE:
chargeApplicationCalendar = CAL_MONTHLY_PERIODIC, init=Jan 5
period: Jan 5 → Feb 5
applyInAdvance=true → operationDate = Jan 5
→ WO #1:
operationDate = 2026-01-05
startDate = 2026-01-05
endDate = 2026-02-05
amount = 49.00 MAD
invoicingDate = 2026-02-05 (from invoicingCalendar.nextDate(Jan 5))
status = OPEN
Step 2: EDRs arrive during January
EDR #1: Jan 10, param1=DATA, quantity=1000 MB
EDR #2: Jan 15, param1=DATA, quantity=2500 MB
EDR #3: Jan 20, param1=DATA, quantity=2000 MB ← crosses 5120 threshold
EDR #4: Jan 22, param1=VOICE, quantity=200 min
EDR #5: Jan 25, param1=DATA, quantity=500 MB
EDR #6: Jan 28, param1=VOICE, quantity=150 min ← crosses 300 threshold
Step 3: UsageRatingJob processes EDRs
EDR #1 (DATA, 1000 MB):
Counter: 0 + 1000 = 1000 (< 5120 ceiling)
Tier 1: 1000 × 0.00 = 0 MAD
→ WO #2: operationDate=Jan 10, amount=0.00, invoicingDate=Feb 1
EDR #2 (DATA, 2500 MB):
Counter: 1000 + 2500 = 3500 (< 5120)
Tier 1: 2500 × 0.00 = 0 MAD
→ WO #3: operationDate=Jan 15, amount=0.00, invoicingDate=Feb 1
EDR #3 (DATA, 2000 MB):
Counter: 3500 + 2000 = 5500 (EXCEEDS 5120!)
Tier 1: 1620 × 0.00 = 0 MAD (remaining in tier)
Tier 2: 380 × 0.10 = 38.00 MAD (overage)
→ WO #4: operationDate=Jan 20, amount=38.00, invoicingDate=Feb 1
EDR #4 (VOICE, 200 min):
Voice counter: 0 + 200 = 200 (< 300)
Tier 1: 200 × 0.00 = 0 MAD
→ WO #5: operationDate=Jan 22, amount=0.00, invoicingDate=Feb 1
EDR #5 (DATA, 500 MB):
Data counter: 5500 + 500 = 6000 (all in Tier 2)
Tier 2: 500 × 0.10 = 50.00 MAD
→ WO #6: operationDate=Jan 25, amount=50.00, invoicingDate=Feb 1
EDR #6 (VOICE, 150 min):
Voice counter: 200 + 150 = 350 (EXCEEDS 300!)
Tier 1: 100 × 0.00 = 0 MAD (remaining)
Tier 2: 50 × 0.50 = 25.00 MAD (overage)
→ WO #7: operationDate=Jan 28, amount=25.00, invoicingDate=Feb 1
Step 4: RT_JOB runs (converts WO → RT)
WO #1 (recurring) → RT #1: usageDate=Jan 5, amount=49.00, invoicingDate=Feb 5
WO #2 (data free) → RT #2: usageDate=Jan 10, amount=0.00, invoicingDate=Feb 1
WO #3 (data free) → RT #3: usageDate=Jan 15, amount=0.00, invoicingDate=Feb 1
WO #4 (data over) → RT #4: usageDate=Jan 20, amount=38.00, invoicingDate=Feb 1
WO #5 (voice free)→ RT #5: usageDate=Jan 22, amount=0.00, invoicingDate=Feb 1
WO #6 (data over) → RT #6: usageDate=Jan 25, amount=50.00, invoicingDate=Feb 1
WO #7 (voice over)→ RT #7: usageDate=Jan 28, amount=25.00, invoicingDate=Feb 1
Step 5: Bill Run on Feb 1 (BillingCycle = CAL_1ST_MONTH)
lastTransactionDate = Feb 1
invoiceUpToDate = Feb 1
SELECT WHERE status=OPEN
AND usageDate >= Jan 1 AND usageDate < Feb 1
AND (invoicingDate IS NULL OR invoicingDate < Feb 1)
RT #1: usageDate=Jan 5 ✓, invoicingDate=Feb 5 → Feb 5 < Feb 1? NO → ❌ EXCLUDED!
RT #2: usageDate=Jan 10 ✓, invoicingDate=Feb 1 → Feb 1 < Feb 1? NO → ❌ EXCLUDED!
Hmm — ALL excluded because invoicingDate = Feb 1 is NOT < Feb 1!
THIS IS THE < vs <= ISSUE. Need to verify: is the query < or <=?
If <=: RT #2-7 included, RT #1 still excluded (Feb 5 > Feb 1)
If <: nothing included — bill run produces empty invoice!
Resolution: The invoicingDate gate uses <= or the bill run uses invoiceUpToDate = lastTransactionDate + 1 day
This is a configuration detail that determines exact behavior. The key takeaway is that the invoicingCalendar must be carefully aligned with the billingCycle to avoid "just missed" scenarios.
Step 6: Bill run on Feb 5 (catches everything)
lastTransactionDate = Feb 5
RT #1: usageDate=Jan 5 ✓, invoicingDate=Feb 5 → Feb 5 <= Feb 5 → ✓ INCLUDED
RT #2-7: usageDate in Jan ✓, invoicingDate=Feb 1 → Feb 1 <= Feb 5 → ✓ INCLUDED
Invoice for SUB_KARIM:
InvoiceLine 1 (ISUB_TEL_BASE): 49.00 MAD (recurring)
validity: Jan 5 → Feb 5
InvoiceLine 2 (ISUB_TEL_DATA): 88.00 MAD (38.00 + 50.00, grouped)
validity: Jan 20 → Jan 25
InvoiceLine 3 (ISUB_TEL_VOICE): 25.00 MAD
validity: Jan 28 → Jan 28
Subtotal: 162.00 MAD
Tax (20%): 32.40 MAD
Total: 194.40 MAD
startDateStrategy — Applicable to BOTH Recurring and Usage
You're right — startDateStrategy should be available for ANY charge that uses a PERIOD-type calendar:
| Charge Type | When startDateStrategy is needed |
|---|---|
| RECURRING | If chargeApplicationCalendar is PERIOD (anniversary) |
| USAGE | If ratingPeriodCalendar is PERIOD (anniversary counter reset) |
| ONE-SHOT | Never (no calendar) |
If calendar is FIXED (CAL_1ST_MONTH, CAL_1ST_WEEK_MON, etc.), startDateStrategy is NOT needed — boundaries are absolute.
startDateStrategy Values
| Value | Init Date Source | Use Case |
|---|---|---|
PRODUCT_ACTIVATION_DATE | serviceInstance.activationDate | Default — most precise anchor |
SUBSCRIPTION_START | subscription.subscriptionDate | Legacy default (current code) |
FIRST_EVENT | First EDR eventDate | Counter starts on first usage |
CALENDAR_START | Not applicable (fixed boundary) | Auto-set for CAL_1ST_* calendars |
CHARGE_DATE | chargeInstance.chargeDate | Explicit override |
Calendar Init Date: startDateStrategy
Current behavior
Calendar period start is initialized with subscriptionDate (hardcoded).
Proposed: configurable via startDateStrategy
| Strategy | Init Date | Use Case |
|---|---|---|
SUBSCRIPTION_START | subscription.subscriptionDate | Current default |
PRODUCT_ACTIVATION_DATE | serviceInstance.activationDate | Proposed new default — more precise for product-based billing |
FIRST_EVENT | First EDR eventDate | Counter starts on first usage |
CALENDAR_START | No init needed (fixed boundary) | For CAL_1ST_MONTH type calendars |
CHARGE_DATE | chargeInstance.chargeDate | Explicit override |
Multi-Value Tracking Clarified
It's NOT "multiple values" — it's "one value tracked per dimension key"
The accumulatedValues map is Map<String, BigDecimal> — one BigDecimal per key string.
SINGLE_VALUE: tracks one total number (e.g., total MB used = 6000) MULTI_VALUE: tracks one number PER key (e.g., US=175 MB, FR=150 MB)
You can't track BOTH quantity AND amount simultaneously in one counter. Each counter tracks ONE metric. If you need both → use TWO counters linked to the same charge.
Default tracking presets
| Preset | counterType | keyEl | valueEl | Description |
|---|---|---|---|---|
| QUANTITY | USAGE | null | #{op.quantity} | Track total quantity |
| AMOUNT | USAGE_AMOUNT | null | #{op.amountWithoutTax} | Track total amount excl. tax |
| AMOUNT_WITH_TAX | USAGE_AMOUNT | null | #{op.amountWithTax} | Track total amount incl. tax |
| QUANTITY_BY_KEY | USAGE | #{op.parameter1} | #{op.quantity} | Quantity per dimension |
| AMOUNT_BY_KEY | USAGE_AMOUNT | #{op.parameter1} | #{op.amountWithoutTax} | Amount per dimension |
initialValue (renamed from ceiling)
initialValueis the starting counter level (NOT a cap)- If not provided: counter starts at 0 and accumulates upward (pure accumulator)
- If provided (e.g., 5120 for "5 GB included"):
- Counter starts at 5120
- Each EDR DECREASES the counter
- When counter reaches 0 → included quota exhausted → overage begins
- Negative values = overage amount
initialValueEl: EL expression for dynamic initial value (e.g., based on subscription attribute)
TODO for Incomplete Field Mapping — MUST COMPLETE
CatalogImportService.java:1007 has incomplete mapping. Must be fixed in implementation:
- Map
accumulator.type→CounterTemplate.accumulatorType - Map
accumulator.trackedValue→CounterTemplate.counterType(USAGE/USAGE_AMOUNT) - Map
accumulator.initialValue→CounterTemplate.ceiling - Map
accumulator.dimensions.key_fx→CounterTemplate.keyEl - Map
accumulator.dimensions.value_fx→CounterTemplate.valueEl - Map
accumulator.dimensions.filter_fx→CounterTemplate.filterEl
Verification Plan
1. TDD — Backend Unit Tests
- RecurringChargeV1ResourceTest: chargeApplicationCalendar, invoicingCalendar, startDateStrategy
- UsageChargeV1ResourceTest: ratingPeriodCalendar, accumulator (single+multi), invoicingCalendar
- ChargeV1MapperTest: all calendar fields returned in GET
2. API Validation — curl/Postman
- Create charges with all new calendar fields
- Verify GET returns them correctly
- Verify counter creation from accumulator payload
3. UI Validation — Browser
- Edit recurring charge → verify Charge Application Calendar + Invoicing Calendar populated
- Edit usage charge → verify Rating Period Calendar + Accumulator fields + Invoicing Calendar
- Verify "Same as" checkbox behavior
4. End-to-End — Provision + Bill Run
- Run provision script → submit EDRs → run jobs → verify invoice
5. Documentation — Docusaurus page + .drawio diagram
Impact on jobs
RecurringRatingJobBean.createRecurringCharges()→ callsRecurringRatingService.rateReccuringCharge()→ callsCalendarService.initializeCalendar(cal, initDate, chargeInstance)- The
initDateis currentlychargeInstance.getSubscriptionDate()(line 708) - Change: read
startDateStrategyfrom charge template → resolve init date accordingly - For
PRODUCT_ACTIVATION_DATE: usechargeInstance.getServiceInstance().getActivationDate()
Standard Calendar Naming Convention
Pattern: CAL_{BOUNDARY}_{FREQUENCY}
Fixed boundary calendars (YEARLY type — ignores init date)
| Code | Type | Boundaries | Description |
|---|---|---|---|
CAL_1ST_MONTH | YEARLY | 1st of each month | Monthly, fixed boundary |
CAL_1ST_QUARTER | YEARLY | Jan 1, Apr 1, Jul 1, Oct 1 | Quarterly, fixed boundary |
CAL_1ST_SEMESTER | YEARLY | Jan 1, Jul 1 | Semi-annual, fixed boundary |
CAL_1ST_YEAR | YEARLY | Jan 1 | Annual, fixed boundary |
CAL_1ST_WEEK_MON | YEARLY | Every Monday | Weekly, Monday start |
CAL_DAILY | YEARLY | Every midnight | Daily, fixed boundary |
Periodic calendars (PERIOD type — anchored to init date / anniversary)
| Code | Type | Period | Description |
|---|---|---|---|
CAL_MONTHLY_PERIODIC | PERIOD(MONTH, 1) | 1 month from init | Monthly anniversary |
CAL_3MONTHLY_PERIODIC | PERIOD(MONTH, 3) | 3 months from init | Quarterly anniversary |
CAL_6MONTHLY_PERIODIC | PERIOD(MONTH, 6) | 6 months from init | Semi-annual anniversary |
CAL_YEARLY_PERIODIC | PERIOD(MONTH, 12) | 12 months from init | Annual anniversary |
CAL_WEEKLY_PERIODIC | PERIOD(DAY, 7) | 7 days from init | Weekly from init date |
CAL_DAILY_PERIODIC | PERIOD(DAY, 1) | 1 day from init | Daily from init date |
CAL_BIWEEKLY_PERIODIC | PERIOD(DAY, 14) | 14 days from init | Bi-weekly from init |
Which to use where?
| Purpose | Recommended | Why |
|---|---|---|
| Recurring charge application | CAL_1ST_MONTH or CAL_MONTHLY_PERIODIC | Fixed = predictable, Periodic = anniversary |
| Usage counter reset (weekly) | CAL_1ST_WEEK_MON | Fixed Monday boundaries |
| Usage counter reset (monthly) | CAL_1ST_MONTH | Reset on 1st, clear for customers |
| Invoicing gate (monthly) | CAL_1ST_MONTH | All WOs eligible on 1st |
| Invoicing gate (daily) | CAL_DAILY | WOs eligible next midnight |
| Commitment term (12 months) | CAL_YEARLY_PERIODIC | Exactly 12 months from start |
Provision Script: Standard Calendar Creation
# ═══ Fixed boundary calendars (YEARLY type — ignores init date) ═══
v1_upsert "calendars" "CAL_1ST_MONTH" '{"code":"CAL_1ST_MONTH","description":"Monthly - 1st of each month","calendarType":"YEARLY","nbPeriods":12}'
v1_upsert "calendars" "CAL_1ST_QUARTER" '{"code":"CAL_1ST_QUARTER","description":"Quarterly - Jan/Apr/Jul/Oct 1st","calendarType":"YEARLY","nbPeriods":4}'
v1_upsert "calendars" "CAL_1ST_SEMESTER" '{"code":"CAL_1ST_SEMESTER","description":"Semi-annual - Jan/Jul 1st","calendarType":"YEARLY","nbPeriods":2}'
v1_upsert "calendars" "CAL_1ST_YEAR" '{"code":"CAL_1ST_YEAR","description":"Annual - Jan 1st","calendarType":"YEARLY","nbPeriods":1}'
v1_upsert "calendars" "CAL_1ST_WEEK_MON" '{"code":"CAL_1ST_WEEK_MON","description":"Weekly - every Monday","calendarType":"YEARLY","nbPeriods":52}'
v1_upsert "calendars" "CAL_DAILY" '{"code":"CAL_DAILY","description":"Daily - midnight to midnight","calendarType":"YEARLY","nbPeriods":365}'
# ═══ Periodic calendars (PERIOD type — anchored to init date / anniversary) ═══
v1_upsert "calendars" "CAL_MONTHLY_PERIODIC" '{"code":"CAL_MONTHLY_PERIODIC","description":"Monthly periodic (anniversary)","calendarType":"PERIOD","periodUnit":"MONTH","periodLength":1}'
v1_upsert "calendars" "CAL_3MONTHLY_PERIODIC" '{"code":"CAL_3MONTHLY_PERIODIC","description":"Quarterly periodic (anniversary)","calendarType":"PERIOD","periodUnit":"MONTH","periodLength":3}'
v1_upsert "calendars" "CAL_6MONTHLY_PERIODIC" '{"code":"CAL_6MONTHLY_PERIODIC","description":"Semi-annual periodic (anniversary)","calendarType":"PERIOD","periodUnit":"MONTH","periodLength":6}'
v1_upsert "calendars" "CAL_YEARLY_PERIODIC" '{"code":"CAL_YEARLY_PERIODIC","description":"Annual periodic (anniversary)","calendarType":"PERIOD","periodUnit":"MONTH","periodLength":12}'
v1_upsert "calendars" "CAL_WEEKLY_PERIODIC" '{"code":"CAL_WEEKLY_PERIODIC","description":"Weekly periodic (from init)","calendarType":"PERIOD","periodUnit":"DAY_OF_MONTH","periodLength":7}'
v1_upsert "calendars" "CAL_BIWEEKLY_PERIODIC" '{"code":"CAL_BIWEEKLY_PERIODIC","description":"Bi-weekly periodic (from init)","calendarType":"PERIOD","periodUnit":"DAY_OF_MONTH","periodLength":14}'
v1_upsert "calendars" "CAL_DAILY_PERIODIC" '{"code":"CAL_DAILY_PERIODIC","description":"Daily periodic (from init)","calendarType":"PERIOD","periodUnit":"DAY_OF_MONTH","periodLength":1}'