[Extensibility Request][Subscription Billing] Extension points for a partner billing extension: Billing Proposal visibility, purchase-side event parity, usage-based billing period, document line creation
Nobody has claimed this yet.
- Dominant language
- AL
- Stars
- 683
- Forks
- 459
- Avg merge
- 3d 26m
- Merged PRs (30d)
- 633
Description
Why do you need this change?
We are moving a per-tenant extension for a German customer from a third-party subscription product onto Microsoft Subscription Billing. The extension adds customer-specific behaviour to contract billing: installment plans, a date-dependent "Your Reference", contract-type header texts, price scales on usage data, and bundle handling from an ISV.
To make that work today we maintain a copy of codeunit 8060 Create Billing Documents and of codeunit 8062 Billing Proposal inside our own extension, because the standard codeunits have no extension point at the places we need. That is the outcome nobody wants: the copies drift from your code silently, they do not receive your fixes, and we have already had to reconcile them by hand after every Subscription Billing release. Concretely, our copy is missing TransferExtendedText, Validate("Unit Cost (LCY)") and the whole Rebilling handling that 28.5 has, because those were added after the copy was made.
The extension points below are what we need to delete those copies and run standard Microsoft code. Three of them are small and low-risk — one visibility change and two plain integration events. They are grouped in one request because they belong to one scenario and to the same two codeunits, as the template asks.
Benefits, per point:
- lets our own recurring-billing page reuse your business logic instead of reimplementing the period recalculation, the authorisation check and the progress tracking.
- removes an asymmetry: everything we can do on customer contract invoices we cannot do on vendor contract invoices, for no functional reason.
- lets a customer bill usage strictly per calendar month when the usage feed already delivers later months, which is currently only possible by clicking through the proposal line by line.
- is the one point that needs a handled event; it is the last thing standing between us and deleting our copy of codeunit 8060.
Not included here, because it is already covered: the procedures a custom Usage Data Connector needs on Usage Data Billing and Create Usage Data Billing are requested in #10019, which is open and approved. We depend on that one as well.
Describe the request
All references are to src/Apps/W1/Subscription Billing/App/. Line numbers are from main; the same code is present in 28.5.
1. Change function visibility — codeunit 8062 Billing Proposal
Page 8067 Recurring Billing drives its actions through this codeunit. Part of that surface is public and part is internal:
| Page action | Procedure | Access |
|---|---|---|
| Create Billing Proposal | CreateBillingProposal (#L234, #L239) |
public |
OnOpenPage / refresh |
InitTempTable (#L36) |
public |
| Clear Billing Proposal | DeleteBillingProposal(BillingTemplateCode: Code[20]) (#L660) |
internal |
| Delete Documents | DeleteBillingDocuments(BillingTemplateCode: Code[20]) (#L1019) |
internal |
| Change Billing To Date | UpdateBillingToDate(var BillingLine: Record "Billing Line"; NewBillingToDate: Date) (#L768) |
internal |
| Delete Billing Line | DeleteBillingLines(var BillingLine: Record "Billing Line") (#L710) |
internal |
UpdateBillingToDate is the only entry point to the period recalculation: it calls the local CalculateBillingPeriod and UpdateBillingLine, plus Subscription Line.UpdateNextBillingDate, and raises OnAfterSubscriptionLineGetInUpdateBillingToDate. There is no other way to shorten a billing line consistently.
We provide our own recurring-billing list — a non-temporary page over Billing Line, because InitTempTable does not scale to our proposal sizes — and want it to offer the same four actions. Today we would have to re-implement the period recalculation, the Billing Template authorisation check (DisplayErrorIfNotAuthorizedToClearProposalOrDeleteDocuments, #L648, already public) and the progress tracking.
Requested change — make these four public, no signature change:
procedure DeleteBillingProposal(BillingTemplateCode: Code[20])
procedure DeleteBillingLines(var BillingLine: Record "Billing Line")
procedure UpdateBillingToDate(var BillingLine: Record "Billing Line"; NewBillingToDate: Date)
procedure DeleteBillingDocuments(BillingTemplateCode: Code[20])
The local overload DeleteBillingDocuments(BillingLine: Record "Billing Line") (#L1096) can stay local. The result is that the public surface of Billing Proposal covers every action of the standard Recurring Billing page, matching CreateBillingProposal and InitTempTable, which are already public.
2. Add new integration events — purchase-side parity in codeunit 8060 Create Billing Documents
The sales side publishes events that the purchase side does not, although the procedures are structurally identical (same Modify(false) after the header is populated, same FirstContractDescriptionLineInserted state):
| Sales procedure | Events | Purchase twin | Events |
|---|---|---|---|
InsertContractDescriptionSalesLines (#L531) |
OnBeforeInsertContractDescriptionSalesLines(..., var IsHandled), OnAfterInsertContractDescriptionSalesLines |
InsertContractDescriptionPurchaseLines (#L585) |
none |
CreateSalesHeaderFromContract (#L627) |
OnAfterCreateSalesHeaderFromContract(CustomerSubscriptionContract, var SalesHeader) |
CreatePurchaseHeaderFromContract (#L668) |
none |
CreateSalesHeaderForCustomerNo (#L708) |
OnAfterCreateSalesHeaderForCustomerNo(var SalesHeader, ContractNo) |
CreatePurchaseHeaderForVendorNo (#L734) |
none |
We use the sales events to put the contract type description, the service recipient address block and the contract's payment terms on customer contract invoices. Vendor contract invoices need the same header lines, and today the only way is to subscribe to OnAfterInsertPurchaseLineFromBillingLine and re-derive whether the line is the first of its contract, which duplicates the grouping logic of your codeunit and breaks whenever that logic changes.
Requested change — four events mirroring the sales side:
[IntegrationEvent(false, false)]
local procedure OnBeforeInsertContractDescriptionPurchaseLines(PurchaseHeader: Record "Purchase Header"; BillingLine: Record "Billing Line"; var FirstContractDescriptionLineInserted: Boolean; VendorRecurringBillingGrouping: Enum "Vendor Rec. Billing Grouping"; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnAfterInsertContractDescriptionPurchaseLines(PurchaseHeader: Record "Purchase Header"; BillingLine: Record "Billing Line"; var FirstContractDescriptionLineInserted: Boolean; VendorRecurringBillingGrouping: Enum "Vendor Rec. Billing Grouping")
begin
end;
[IntegrationEvent(false, false)]
local procedure OnAfterCreatePurchaseHeaderFromContract(VendorSubscriptionContract: Record "Vendor Subscription Contract"; var PurchaseHeader: Record "Purchase Header")
begin
end;
[IntegrationEvent(false, false)]
local procedure OnAfterCreatePurchaseHeaderForVendorNo(var PurchaseHeader: Record "Purchase Header"; ContractNo: Code[20])
begin
end;
Placement mirroring the sales side: the description events at the top of InsertContractDescriptionPurchaseLines (wrapped in if not IsHandled then begin ... end;) and at its end, as in #L537-L552; the header events directly before the final PurchaseHeader.Modify(false) in CreatePurchaseHeaderFromContract (#L704) and CreatePurchaseHeaderForVendorNo (#L754), matching #L659-L660 and #L725-L726.
VendorRecurringBillingGrouping is an existing global of the codeunit and Enum "Vendor Rec. Billing Grouping" is the type already used in OnBeforeProcessBillingLines, so the signatures compile as written.
3. Add a new integration event — billing period of usage-based Subscription Lines, codeunit 8062 Billing Proposal
CalculateBillingPeriod (Billing/Codeunits/BillingProposal.Codeunit.al#L590-L624) derives the period of a usage-based line from the first Charge Start Date to the last Charge End Date of all open Usage Data Billing rows, and returns from that branch before any event is reached:
if ServiceCommitment."Usage Based Billing" then begin
UsageDataBilling.SetCurrentKey("Charge End Date");
UsageDataBilling.SetAscending("Charge End Date", true);
UsageDataBilling.SetRange("Subscription Header No.", ServiceCommitment."Subscription Header No.");
UsageDataBilling.SetRange("Subscription Line Entry No.", ServiceCommitment."Entry No.");
UsageDataBilling.SetRange(Partner, ServiceCommitment.Partner);
UsageDataBilling.SetRange("Document Type", "Usage Based Billing Doc. Type"::None);
if UsageDataBilling.FindFirst() then
BillingPeriodStart := UsageDataBilling."Charge Start Date";
if UsageDataBilling.FindLast() then
BillingPeriodEnd := UsageDataBilling."Charge End Date";
exit;
end;
OnAfterCalculateNextBillingToDateForSubscriptionLine (#L645) sits in CalculateNextBillingToDateForServiceCommitment, which this branch never calls, and OnProcessSubscriptionLineOnAfterFilterUsageDataBilling (#L388) only affects the skip decision on a different local record.
Our customer bills usage strictly per calendar month, while the supplier feed delivers later months ahead of time. With January and February usage open, the proposal line always spans 01/01–28/02, and the only workaround is the manual Change Billing To Date action on every line.
Requested change — one plain (non-handled) event in that branch:
[IntegrationEvent(false, false)]
local procedure OnAfterCalculateBillingPeriodForUsageBasedSubscriptionLine(SubscriptionLine: Record "Subscription Line"; var UsageDataBilling: Record "Usage Data Billing"; var BillingPeriodStart: Date; var BillingPeriodEnd: Date)
begin
end;
raised after the two dates are derived and before the exit. This point is only useful together with the document-stamping defect reported in #11326: without that fix, the usage rows of the periods left open would still be marked as invoiced by the first document. Raising it before the FindFirst instead, with the filtered UsageDataBilling passed by var, would work equally well for us and keeps your FindFirst / FindLast as the single place that derives the dates — we have no preference.
4. Add a new IsHandled event — document line creation in codeunit 8060 Create Billing Documents
This is the only handled event in the request. Following the minimum requirements for IsHandled events:
Problem statement. Bundle components sold through an ISV bundle solution carry unit price 0 on the component Subscription Lines, because the price belongs to the bundle header line. When such a contract is billed, every component becomes a zero-priced Type = Item sales line that goes through inventory and posting for no value. The customer's invoice layout requires these components as description lines attached to the bundle line. InsertSalesLineFromTempBillingLine (#L239) and InsertPurchaseLineFromTempBillingLine (#L394) build the item line unconditionally, and no event lets an extension produce a different line for a given billing line.
Alternatives evaluated.
OnBeforeInsertSalesLineFromContractLine— fires afterValidate("No."), unit of measure,Validate(Quantity, ...), price and discount have already run, and has noIsHandled. The item validation we need to avoid has already happened, and the line type cannot be changed at that point without re-validating.OnAfterInsertSalesLineFromBillingLine— the line is inserted. Deleting and replacing it re-runs item validation and, worse, breaks theBilling LineandUsage Data Billingdocument references that the procedure has already written.OnAfterCustomerContractLineGetInInsertSalesLineFromTempBillingLine— informational, cannot influence the line.- Removing the components from
TempBillingLineinOnCreateSalesDocumentsPerCustomerBeforeTempBillingLineFindSet— the lines disappear from the invoice entirely, including the description text the customer needs, and the billing lines are then left unstamped. - Page or table extension — the line is created in code, so neither applies.
Justification for IsHandled. The decision is per billing line and the alternative behaviour is a different line type, so no plain event placed before or after the existing code can express it. Only skipping the standard creation for the affected lines does.
Proposed publisher location. Object: codeunit 8060 Create Billing Documents. Procedure: InsertSalesLineFromTempBillingLine, and the same in InsertPurchaseLineFromTempBillingLine. Placement rationale: immediately after the existing OnAfterCustomerContractLineGetInInsertSalesLineFromTempBillingLine, which is the last point before any line construction begins — the narrowest possible scope, and the subscriber gets the same context the standard code has.
Proposed code (before → after).
Before:
ServiceObject.Get(TempBillingLine."Subscription Header No.");
ServiceCommitment.Get(TempBillingLine."Subscription Line Entry No.");
CustomerContractLine.Get(TempBillingLine."Subscription Contract No.", TempBillingLine."Subscription Contract Line No.");
OnAfterCustomerContractLineGetInInsertSalesLineFromTempBillingLine(CustomerContractLine, SalesHeader, TempBillingLine);
SalesLine.InitFromSalesHeader(SalesHeader);
After:
ServiceObject.Get(TempBillingLine."Subscription Header No.");
ServiceCommitment.Get(TempBillingLine."Subscription Line Entry No.");
CustomerContractLine.Get(TempBillingLine."Subscription Contract No.", TempBillingLine."Subscription Contract Line No.");
OnAfterCustomerContractLineGetInInsertSalesLineFromTempBillingLine(CustomerContractLine, SalesHeader, TempBillingLine);
IsHandled := false;
OnBeforeInsertSalesLineFromTempBillingLine(TempBillingLine, SalesHeader, IsHandled);
if IsHandled then
exit;
SalesLine.InitFromSalesHeader(SalesHeader);
with
[IntegrationEvent(false, false)]
local procedure OnBeforeInsertSalesLineFromTempBillingLine(var TempBillingLine: Record "Billing Line" temporary; var SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeInsertPurchaseLineFromTempBillingLine(var TempBillingLine: Record "Billing Line" temporary; var PurchaseHeader: Record "Purchase Header"; var IsHandled: Boolean)
begin
end;
No other change to the surrounding code, and nothing after the check that the standard path relies on.
Dependency: one visibility change. A subscriber that handles the line must stamp the document keys the standard loop would have written. Billing Line fields and GetBillingDocumentTypeFromSalesDocumentType are public, but Usage Data Billing.SaveDocumentValues (Usage Based Billing/Tables/UsageDataBilling.Table.al#L662) is internal. Please make it public together with this event, otherwise the handled path cannot leave the usage data in the state your code expects:
procedure SaveDocumentValues(UsageBasedBillingDocType: Enum "Usage Based Billing Doc. Type"; DocumentNo: Code[20]; DocumentEntryNo: Integer; BillingLineEntryNo: Integer)
Subscriber example (illustrative).
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Create Billing Documents", OnBeforeInsertSalesLineFromTempBillingLine, '', false, false)]
local procedure HandleZeroPricedBundleComponent(var TempBillingLine: Record "Billing Line" temporary; var SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
var
CustSubContractLine: Record "Cust. Sub. Contract Line";
begin
if TempBillingLine."Unit Price" <> 0 then
exit;
if not CustSubContractLine.Get(TempBillingLine."Subscription Contract No.", TempBillingLine."Subscription Contract Line No.") then
exit;
if not IsBundleComponent(CustSubContractLine) then
exit;
InsertDescriptionLineForComponent(SalesHeader, TempBillingLine); // our line
StampBillingAndUsageData(SalesHeader, TempBillingLine); // uses SaveDocumentValues
IsHandled := true;
end;
Performance considerations. The event is raised once per billing line that becomes a document line — the same frequency as the existing OnBeforeInsertSalesLineFromContractLine in the same procedure. No loop, no additional read, no external call. With no subscriber the cost is one publisher call per line.
Data sensitivity review. The parameters are the temporary Billing Line and the Sales Header (respectively Purchase Header) that the procedure already holds. No customer master data, no credentials, no security-relevant data beyond what OnBeforeInsertSalesLineFromContractLine already exposes a few lines later.
Multi-extension interaction. The risk is two subscribers claiming the same billing line. Each subscriber is expected to test a condition specific to its own data before setting IsHandled — in our case an ISV bundle field on the contract line — so overlap requires two extensions to own the same line, which is already a functional conflict at the contract level. A subscriber must not set IsHandled unconditionally. If ordering guarantees are a concern for you, we are equally happy with the event being raised only when no line has been created yet, i.e. exactly where it is proposed.
Test adjustments. We expect none for existing tests: with no subscriber the behaviour is unchanged. A new test would need a subscriber in the test app that handles one line and asserts the standard line is not created while the others are.
Provide an implementation (optional)
- I will provide the implementation for this extensibility request
(Tick this if you want to submit the PR. Items 1 and 2 are mechanical; item 4 is the only one with design discussion.)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/Apps/W1/Subscription Billing/App/ with codeunits 8060 Create Billing Documents and 8062 Billing Proposal. Read the named procedures: the Billing Proposal visibility targets, purchase-side header and description procedures, CalculateBillingPeriod, and the document-line insertion entry points. Done means the requested public procedures and integration events are available without copying the standard codeunits; confirm the changes compile.
Written by the indexing model from the issue text.
Assessment
- Domain
- backend, payments
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100