Skip to content

RAP Project Example: An Expense Report App with Header and Items

In the RAP guide I explained the concepts using a single entity. Real applications rarely consist of one table: there is a header and items, the total is calculated from the items, and the screen layout matters as much as the rules. In this article I build a small but complete project — an expense report app — from start to finish.

Mustafa Önder Mustafa Önder  ·  4 October 2026  ·  25 min read

Contents

  1. Project Scope: What Are We Building?
  2. Object List and Naming
  3. Step 1: Two Tables and a Composition
  4. Step 2: Behavior Definition for Header and Items
  5. Step 3: From Item to Header — Currency and Total
  6. Step 4: Submit, Approve, Reject
  7. Step 5: Projection and Service
  8. Step 6: The Screen with Metadata Extensions
  9. Step 7: Read Authorization for Items
  10. Creation Order in ADT
  11. Test Scenarios
  12. What I Deliberately Left Out
  13. Common Mistakes in Header–Item Objects
  14. Conclusion

In the SAP RAP guide I used a draft-enabled leave request to explain RAP's layers, the behavior definition, determinations, validations, actions, EML and unmanaged save. This article is its sequel: instead of explaining the same concepts again, I focus on the questions you face on day one of a real project. How are items attached to the header? Where is the total calculated? How does the screen update when the user changes an item? Where does the items' authorization come from?

This is an example project. It is not a real customer application; the scenario was chosen to show the decisions RAP asks of you in a header–item structure. Code blocks are shortened for readability. The example assumes a current S/4HANA or SAP BTP ABAP Environment system with the ABAP Cloud language version. Some BDEF features such as side effects are not available in older releases; check them on your own release.

Project Scope: What Are We Building?

The scenario is a process everyone knows: an employee opens an expense report, enters the expenses line by line and submits the report for approval. The manager approves it or rejects it with a reason. What we expect from the app:

  • Header: employee, description, currency, total amount, status and rejection reason.
  • Items: expense date, expense type, amount and note. A report has a single currency; items use the header's currency.
  • Total: not typed by the user — calculated from the items and updated on screen as items change.
  • Status flow: Open → Submitted → Approved / Rejected. A rejected report can be corrected and resubmitted.
  • Rules: an item amount must be greater than zero, a report without items cannot be submitted, a rejection reason is mandatory.
  • Draft: the user must be able to leave a report half-finished and continue later.

Object List and Naming

Starting a RAP project with an object list makes both estimating and splitting the work within a team easier. These are the objects for this project, all in one package (ZEXPENSE):

Objects in the expense report RAP project, their types and responsibilities
ObjectTypeResponsibility
ZEXP_REP, ZEXP_ITEMDatabase tableHeader and item data
ZEXP_REP_D, ZEXP_ITEM_DDraft tableUnsaved changes (generated by ADT)
ZR_ExpenseReport, ZR_ExpenseItemCDS view entityThe business object's data model (interface layer)
ZR_ExpenseReportBehavior definitionThe behavioural contract; both entities in one BDEF
ZBP_R_EXPENSEREPORTABAP class (behavior pool)Determination, validation and action code
ZD_RejectReasonAbstract entityParameter of the reject action
ZC_ExpenseReport, ZC_ExpenseItemProjection view + projection BDEFThe slice exposed to the Fiori app
ZC_ExpenseReport, ZC_ExpenseItemMetadata extensionLayout of the list, object page and buttons
ZUI_EXPENSE, ZUI_EXPENSE_O4Service definition / bindingOData V4 - UI service
ZR_EXPENSEREPORT, ZR_EXPENSEITEMAccess control (DCL)Read authorization
ZEXPENSEMessage classValidation and action messages

The prefixes follow the habit SAP uses in its own development: R_ for the reusable layer of the business object, C_ for the projection for a specific app. If your team has an established naming convention, use that; what matters is that the layer can be read from the object name.

Step 1: Two Tables and a Composition

The header table contains RAP's administrative fields, as in the guide. What is new is the currency-dependent amount field: @Semantics.amount.currencyCode tells which currency field the amount is interpreted against.

@EndUserText.label : 'Expense report header'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zexp_rep {
  key client            : abap.clnt not null;
  key report_uuid       : sysuuid_x16 not null;
  employee_id           : abap.char(8);
  description           : abap.char(80);
  @Semantics.amount.currencyCode : 'zexp_rep.currency_code'
  total_amount          : abap.curr(15,2);
  currency_code         : abap.cuky;
  status                : abap.char(1);
  reject_reason         : abap.char(100);
  local_created_by      : abp_creation_user;
  local_created_at      : abp_creation_tstmpl;
  local_last_changed_by : abp_locinst_lastchange_user;
  local_last_changed_at : abp_locinst_lastchange_tstmpl;
  last_changed_at       : abp_lastchange_tstmpl;
}

The item table carries its own UUID key and a report_uuid field pointing to the header. The child entity does not need created/changed-by information; local_last_changed_at is enough for concurrency control:

@EndUserText.label : 'Expense report item'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zexp_item {
  key client            : abap.clnt not null;
  key item_uuid         : sysuuid_x16 not null;
  report_uuid           : sysuuid_x16;
  expense_date          : abap.dats;
  expense_type          : abap.char(2);
  @Semantics.amount.currencyCode : 'zexp_item.currency_code'
  amount                : abap.curr(15,2);
  currency_code         : abap.cuky;
  note                  : abap.char(80);
  local_last_changed_at : abp_locinst_lastchange_tstmpl;
}

The relationship is defined in the CDS layer. The root entity declares the items with a composition; the item knows its parent through association to parent. These two lines tell RAP "the item is part of the header": an item cannot exist without a header, deleting the header deletes its items, and locking and draft are managed from the header.

@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Expense report'
define root view entity ZR_ExpenseReport
  as select from zexp_rep
  composition [0..*] of ZR_ExpenseItem as _Item
{
  key report_uuid           as ReportUuid,
      employee_id           as EmployeeId,
      description           as Description,
      @Semantics.amount.currencyCode: 'CurrencyCode'
      total_amount          as TotalAmount,
      currency_code         as CurrencyCode,
      status                as Status,
      reject_reason         as RejectReason,
      @Semantics.user.createdBy: true
      local_created_by      as LocalCreatedBy,
      @Semantics.systemDateTime.createdAt: true
      local_created_at      as LocalCreatedAt,
      @Semantics.user.localInstanceLastChangedBy: true
      local_last_changed_by as LocalLastChangedBy,
      @Semantics.systemDateTime.localInstanceLastChangedAt: true
      local_last_changed_at as LocalLastChangedAt,
      @Semantics.systemDateTime.lastChangedAt: true
      last_changed_at       as LastChangedAt,

      _Item
}
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Expense report item'
define view entity ZR_ExpenseItem
  as select from zexp_item
  association to parent ZR_ExpenseReport as _Report
    on $projection.ReportUuid = _Report.ReportUuid
{
  key item_uuid             as ItemUuid,
      report_uuid           as ReportUuid,
      expense_date          as ExpenseDate,
      expense_type          as ExpenseType,
      @Semantics.amount.currencyCode: 'CurrencyCode'
      amount                as Amount,
      currency_code         as CurrencyCode,
      note                  as Note,
      @Semantics.systemDateTime.localInstanceLastChangedAt: true
      local_last_changed_at as LocalLastChangedAt,

      _Report
}

The item's key is its own UUID only. ReportUuid is not a key but an ordinary field; RAP fills it itself when the item is created through the header. That is why the item code can rely on ReportUuid to find the header.

Step 2: Behavior Definition for Header and Items

Both entities are defined in a single behavior definition. Compared with the single-entity example in the guide, I explain the new lines one by one below:

managed implementation in class zbp_r_expensereport unique;
strict ( 2 );
with draft;

define behavior for ZR_ExpenseReport alias ExpenseReport
persistent table zexp_rep
draft table zexp_rep_d
lock master
total etag LastChangedAt
authorization master ( global, instance )
etag master LocalLastChangedAt
{
  create;
  update;
  delete;
  association _Item { create; with draft; }

  field ( numbering : managed, readonly ) ReportUuid;
  field ( readonly ) TotalAmount, Status, RejectReason,
                     LocalCreatedBy, LocalCreatedAt,
                     LocalLastChangedBy, LocalLastChangedAt, LastChangedAt;
  field ( mandatory ) EmployeeId, Description, CurrencyCode;

  determination setInitialStatus on modify { create; }
  determination syncItemCurrency on modify { field CurrencyCode; }

  action ( features : instance ) submit result [1] $self;
  action ( features : instance ) approve result [1] $self;
  action ( features : instance ) reject parameter ZD_RejectReason result [1] $self;
  internal action recalcTotal;

  side effects
  {
    field CurrencyCode affects entity _Item;
  }

  draft action Edit;
  draft action Activate optimized;
  draft action Discard;
  draft action Resume;
  draft determine action Prepare
  {
    validation ExpenseItem~validateAmount;
  }

  mapping for zexp_rep
  {
    ReportUuid         = report_uuid;
    EmployeeId         = employee_id;
    Description        = description;
    TotalAmount        = total_amount;
    CurrencyCode       = currency_code;
    Status             = status;
    RejectReason       = reject_reason;
    LocalCreatedBy     = local_created_by;
    LocalCreatedAt     = local_created_at;
    LocalLastChangedBy = local_last_changed_by;
    LocalLastChangedAt = local_last_changed_at;
    LastChangedAt      = last_changed_at;
  }
}

define behavior for ZR_ExpenseItem alias ExpenseItem
persistent table zexp_item
draft table zexp_item_d
lock dependent by _Report
authorization dependent by _Report
etag master LocalLastChangedAt
{
  update;
  delete;
  association _Report { with draft; }

  field ( numbering : managed, readonly ) ItemUuid;
  field ( readonly ) ReportUuid, CurrencyCode, LocalLastChangedAt;
  field ( mandatory ) ExpenseDate, ExpenseType, Amount;

  determination setCurrency on modify { create; }
  determination recalcReportTotal on modify { create; field Amount; }
  validation validateAmount on save { create; field Amount; }

  side effects
  {
    field Amount affects field _Report.TotalAmount;
  }

  mapping for zexp_item
  {
    ItemUuid           = item_uuid;
    ReportUuid         = report_uuid;
    ExpenseDate        = expense_date;
    ExpenseType        = expense_type;
    Amount             = amount;
    CurrencyCode       = currency_code;
    Note               = note;
    LocalLastChangedAt = local_last_changed_at;
  }
}
  • association _Item { create; with draft; }: Items are only created through the header, which is why the item entity has no standalone create. with draft makes sure an item is created as a draft while the header is in draft. The item's association _Report { with draft; } says the same in the other direction.
  • lock dependent by _Report, authorization dependent by _Report: The item has no lock or authorization check of its own; both come from the header. When the user changes an item, the whole report is locked.
  • authorization master ( global, instance ): In the guide I only used instance. global checks operations for which no record exists yet (such as creating a report); instance checks changes and actions on a specific report. If you also want to restrict who may create reports, you need both.
  • internal action recalcTotal: An action that is not exposed and can only be called from the object's own code. I use it to keep the total calculation in one place.
  • parameter ZD_RejectReason: The reject action takes a parameter. Fiori Elements automatically opens an input dialog for it when the button is pressed.
  • side effects: Tells the screen "when this field changes, reload that". When an item amount changes the header total is refreshed, and when the header currency changes the item table is. Without this the value is calculated correctly in the background, but the user sees the old figure until they refresh.
  • validation ExpenseItem~validateAmount: The child entity's validation is added to the header's Prepare action with the entity name as a prefix, so item errors also show up during the draft phase.

The item currency is read-only: the user does not type it, it is copied from the header. This is a deliberate simplification; supporting receipts in foreign currencies requires currency conversion, which is outside the scope of this example.

The reject parameter: an abstract entity

Action parameters are defined with an abstract entity, which has no counterpart in the database:

@EndUserText.label: 'Rejection reason'
define abstract entity ZD_RejectReason
{
  @EndUserText.label: 'Reason'
  RejectReason : abap.char(100);
}

Step 3: From Item to Header — Currency and Total

The behavior pool contains two handler classes: one for the header and one for the items. ADT generates the method skeleton for every determination, validation and action in the BDEF via a quick fix:

CLASS lhc_expensereport DEFINITION INHERITING FROM cl_abap_behavior_handler.
  PRIVATE SECTION.
    CONSTANTS:
      BEGIN OF status,
        open      TYPE c LENGTH 1 VALUE 'O',
        submitted TYPE c LENGTH 1 VALUE 'S',
        approved  TYPE c LENGTH 1 VALUE 'A',
        rejected  TYPE c LENGTH 1 VALUE 'R',
      END OF status.

    METHODS get_global_authorizations FOR GLOBAL AUTHORIZATION
      IMPORTING REQUEST requested_authorizations FOR ExpenseReport RESULT result.
    METHODS get_instance_authorizations FOR INSTANCE AUTHORIZATION
      IMPORTING keys REQUEST requested_authorizations FOR ExpenseReport RESULT result.
    METHODS get_instance_features FOR INSTANCE FEATURES
      IMPORTING keys REQUEST requested_features FOR ExpenseReport RESULT result.
    METHODS set_initial_status FOR DETERMINE ON MODIFY
      IMPORTING keys FOR ExpenseReport~setInitialStatus.
    METHODS sync_item_currency FOR DETERMINE ON MODIFY
      IMPORTING keys FOR ExpenseReport~syncItemCurrency.
    METHODS submit FOR MODIFY
      IMPORTING keys FOR ACTION ExpenseReport~submit RESULT result.
    METHODS approve FOR MODIFY
      IMPORTING keys FOR ACTION ExpenseReport~approve RESULT result.
    METHODS reject FOR MODIFY
      IMPORTING keys FOR ACTION ExpenseReport~reject RESULT result.
    METHODS recalc_total FOR MODIFY
      IMPORTING keys FOR ACTION ExpenseReport~recalcTotal.
ENDCLASS.

CLASS lhc_expenseitem DEFINITION INHERITING FROM cl_abap_behavior_handler.
  PRIVATE SECTION.
    METHODS set_currency FOR DETERMINE ON MODIFY
      IMPORTING keys FOR ExpenseItem~setCurrency.
    METHODS recalc_report_total FOR DETERMINE ON MODIFY
      IMPORTING keys FOR ExpenseItem~recalcReportTotal.
    METHODS validate_amount FOR VALIDATE ON SAVE
      IMPORTING keys FOR ExpenseItem~validateAmount.
ENDCLASS.

set_initial_status and approve follow exactly the pattern from the guide, so I do not repeat them here. The authorization methods hold nothing new either: you run an AUTHORITY-CHECK against your own authorization object and write the outcome to result.

The item currency comes from the header

When a new item is created, the header's currency is copied to it. If the header currency changes later, the second determination updates all items. Both rely on the same idea: the item's ReportUuid and draft state are enough to find the right header.

METHOD set_currency.
  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem
      FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
    RESULT DATA(items).

  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem BY \_Report
      FIELDS ( CurrencyCode ) WITH CORRESPONDING #( keys )
    RESULT DATA(reports).

  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem
      UPDATE FIELDS ( CurrencyCode )
      WITH VALUE #( FOR item IN items
                    ( %tky         = item-%tky
                      CurrencyCode = reports[ %is_draft  = item-%is_draft
                                              ReportUuid = item-ReportUuid ]-CurrencyCode ) ).
ENDMETHOD.

" in lhc_expensereport
METHOD sync_item_currency.
  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      FIELDS ( CurrencyCode ) WITH CORRESPONDING #( keys )
    RESULT DATA(reports).

  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport BY \_Item
      FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
    RESULT DATA(items).

  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem
      UPDATE FIELDS ( CurrencyCode )
      WITH VALUE #( FOR item IN items
                    ( %tky         = item-%tky
                      CurrencyCode = reports[ %is_draft  = item-%is_draft
                                              ReportUuid = item-ReportUuid ]-CurrencyCode ) ).
ENDMETHOD.

Including %is_draft in the match matters: the same report can have an active and a draft version at the same time, and both share the same ReportUuid.

The total is calculated in one place

The total is calculated in the internal action recalcTotal. The item-side determination only says "recalculate my parent's total"; it knows nothing about the calculation itself:

" in lhc_expenseitem
METHOD recalc_report_total.
  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem BY \_Report
      FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
    RESULT DATA(reports).

  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      EXECUTE recalcTotal FROM CORRESPONDING #( reports ).
ENDMETHOD.

" in lhc_expensereport
METHOD recalc_total.
  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      FIELDS ( TotalAmount ) WITH CORRESPONDING #( keys )
    RESULT DATA(reports).

  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport BY \_Item
      FIELDS ( ReportUuid Amount ) WITH CORRESPONDING #( keys )
    RESULT DATA(items).

  LOOP AT reports ASSIGNING FIELD-SYMBOL(<report>).
    CLEAR <report>-TotalAmount.
    LOOP AT items INTO DATA(item)
         WHERE ReportUuid = <report>-ReportUuid
           AND %is_draft  = <report>-%is_draft.
      <report>-TotalAmount += item-Amount.
    ENDLOOP.
  ENDLOOP.

  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      UPDATE FIELDS ( TotalAmount ) WITH CORRESPONDING #( reports ).
ENDMETHOD.

The benefit of this split shows when another place needs to recalculate the total: the submit action below calls the same internal action. If the calculation rule changes (for example, some expense types should be excluded from the total), you change a single method.

What happens when an item is deleted? The triggers of recalcReportTotal are item creation and amount changes; deletion is not on the list. When an item is deleted in a draft, the total on screen and the saved total may stay stale until another item changes. In this example I guarantee that the figure sent for approval is always correct by recalculating the total once more in the submit action. If you also want it updated at the moment of deletion, check whether your release supports delete as a determination trigger for child entities.

Item validation and %path

The validation pattern is the same as in the guide; the only difference is that for child entities the message is linked to the parent with %path. Fiori Elements uses this path information to find the faulty row in the item table:

METHOD validate_amount.
  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem
      FIELDS ( Amount ) WITH CORRESPONDING #( keys )
    RESULT DATA(items).

  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseItem BY \_Report
      FROM CORRESPONDING #( items )
    LINK DATA(links).

  LOOP AT items INTO DATA(item).
    APPEND VALUE #( %tky        = item-%tky
                    %state_area = 'VALIDATE_AMOUNT' ) TO reported-expenseitem.

    IF item-Amount <= 0.
      APPEND VALUE #( %tky = item-%tky ) TO failed-expenseitem.
      APPEND VALUE #( %tky            = item-%tky
                      %state_area     = 'VALIDATE_AMOUNT'
                      %msg            = new_message(
                                          id       = 'ZEXPENSE'
                                          number   = '001'
                                          severity = if_abap_behv_message=>severity-error )
                      %path           = VALUE #( expensereport-%tky =
                                          links[ KEY id source-%tky = item-%tky ]-target-%tky )
                      %element-Amount = if_abap_behv=>mk-on ) TO reported-expenseitem.
    ENDIF.
  ENDLOOP.
ENDMETHOD.

Step 4: Submit, Approve, Reject

Submitting: refresh the total, refuse empty reports

submit does two things: it recalculates the total from the items and refuses reports without items. Reports that fail are written to failed and reported; only the valid ones change status:

METHOD submit.
  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      EXECUTE recalcTotal FROM CORRESPONDING #( keys ).

  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport BY \_Item
      FIELDS ( ReportUuid ) WITH CORRESPONDING #( keys )
    RESULT DATA(items).

  DATA(valid_keys) = keys.
  LOOP AT valid_keys INTO DATA(report_key).
    IF NOT line_exists( items[ ReportUuid = report_key-ReportUuid ] ).
      APPEND VALUE #( %tky = report_key-%tky ) TO failed-expensereport.
      APPEND VALUE #( %tky = report_key-%tky
                      %msg = new_message(
                               id       = 'ZEXPENSE'
                               number   = '002'
                               severity = if_abap_behv_message=>severity-error ) )
             TO reported-expensereport.
      DELETE valid_keys.
    ENDIF.
  ENDLOOP.

  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      UPDATE FIELDS ( Status )
      WITH VALUE #( FOR valid IN valid_keys
                    ( %tky   = valid-%tky
                      Status = status-submitted ) ).

  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      ALL FIELDS WITH CORRESPONDING #( valid_keys )
    RESULT DATA(reports).

  result = VALUE #( FOR report IN reports
                    ( %tky   = report-%tky
                      %param = report ) ).
ENDMETHOD.

I did not include the draft state in this check because the feature control below only enables the action on saved (active) reports.

Rejecting with a parameter

The parameter arrives under %param in each key row. If the reason is empty, the report is not rejected:

METHOD reject.
  DATA(valid_keys) = keys.
  LOOP AT valid_keys INTO DATA(report_key).
    IF report_key-%param-RejectReason IS INITIAL.
      APPEND VALUE #( %tky = report_key-%tky ) TO failed-expensereport.
      APPEND VALUE #( %tky = report_key-%tky
                      %msg = new_message(
                               id       = 'ZEXPENSE'
                               number   = '003'
                               severity = if_abap_behv_message=>severity-error ) )
             TO reported-expensereport.
      DELETE valid_keys.
    ENDIF.
  ENDLOOP.

  MODIFY ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      UPDATE FIELDS ( Status RejectReason )
      WITH VALUE #( FOR valid IN valid_keys
                    ( %tky         = valid-%tky
                      Status       = status-rejected
                      RejectReason = valid-%param-RejectReason ) ).

  " The result is filled with the current records, as in submit
ENDMETHOD.

Which button is enabled when?

The status flow is built with feature control. For a report in draft, all three actions are disabled; the user has to save first:

METHOD get_instance_features.
  READ ENTITIES OF zr_expensereport IN LOCAL MODE
    ENTITY ExpenseReport
      FIELDS ( Status ) WITH CORRESPONDING #( keys )
    RESULT DATA(reports).

  result = VALUE #( FOR report IN reports
    LET is_active  = xsdbool( report-%is_draft = if_abap_behv=>mk-off )
        can_submit = COND #( WHEN is_active = abap_true
                               AND ( report-Status = status-open
                                  OR report-Status = status-rejected )
                             THEN if_abap_behv=>fc-o-enabled
                             ELSE if_abap_behv=>fc-o-disabled )
        can_decide = COND #( WHEN is_active = abap_true
                               AND report-Status = status-submitted
                             THEN if_abap_behv=>fc-o-enabled
                             ELSE if_abap_behv=>fc-o-disabled )
    IN ( %tky            = report-%tky
         %action-submit  = can_submit
         %action-approve = can_decide
         %action-reject  = can_decide ) ).
ENDMETHOD.

The message class ZEXPENSE holds three messages: 001 "Amount must be greater than zero", 002 "A report without items cannot be submitted", 003 "A rejection reason is required".

Step 5: Projection and Service

In the projection views the composition is redirected to the new layer with redirected to. Without it, the service would see a relationship pointing to the underlying interface entity instead of the projection:

@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
@EndUserText.label: 'Expense report - projection'
define root view entity ZC_ExpenseReport
  provider contract transactional_query
  as projection on ZR_ExpenseReport
{
  key ReportUuid,
      EmployeeId,
      Description,
      TotalAmount,
      @Consumption.valueHelpDefinition: [{ entity: { name: 'I_CurrencyStdVH', element: 'Currency' } }]
      CurrencyCode,
      Status,
      RejectReason,
      LocalLastChangedAt,
      LastChangedAt,

      _Item : redirected to composition child ZC_ExpenseItem
}
@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
@EndUserText.label: 'Expense report item - projection'
define view entity ZC_ExpenseItem
  as projection on ZR_ExpenseItem
{
  key ItemUuid,
      ReportUuid,
      ExpenseDate,
      ExpenseType,
      Amount,
      CurrencyCode,
      Note,
      LocalLastChangedAt,

      _Report : redirected to parent ZC_ExpenseReport
}

The value help on the currency field uses SAP's released standard currency view. For the expense type you would add a domain with fixed values and a small value help view on top of it; I leave it out here to keep the article short.

The projection BDEF has two new lines: use side effects and use association for the items. The internal action recalcTotal does not appear here; it is not exposed:

projection;
strict ( 2 );
use draft;
use side effects;

define behavior for ZC_ExpenseReport alias ExpenseReport
use etag
{
  use create;
  use update;
  use delete;

  use action submit;
  use action approve;
  use action reject;

  use action Edit;
  use action Activate;
  use action Discard;
  use action Resume;
  use action Prepare;

  use association _Item { create; with draft; }
}

define behavior for ZC_ExpenseItem alias ExpenseItem
use etag
{
  use update;
  use delete;

  use association _Report { with draft; }
}
@EndUserText.label: 'Expense report service'
define service ZUI_EXPENSE {
  expose ZC_ExpenseReport as ExpenseReport;
  expose ZC_ExpenseItem   as ExpenseItem;
}

Once you create and publish an OData V4 - UI binding named ZUI_EXPENSE_O4 from the service definition, the Preview in the binding editor opens a working List Report / Object Page app. But one more step is needed before the screen becomes usable.

Step 6: The Screen with Metadata Extensions

A preview without annotations shows a list with no columns; metadata extensions decide which columns, filters, sections and buttons appear. Putting the screen description into a separate object instead of the CDS view lets you change the screen without cluttering the data model. I covered this Fiori Elements approach in detail in Fiori Elements or Freestyle?.

@Metadata.layer: #CORE
@UI.headerInfo: {
  typeName: 'Expense Report',
  typeNamePlural: 'Expense Reports',
  title: { type: #STANDARD, value: 'Description' },
  description: { type: #STANDARD, value: 'EmployeeId' }
}
annotate entity ZC_ExpenseReport with
{
  @UI.facet: [
    { id: 'General', purpose: #STANDARD, type: #IDENTIFICATION_REFERENCE,
      label: 'General Information', position: 10 },
    { id: 'Items', purpose: #STANDARD, type: #LINEITEM_REFERENCE,
      label: 'Items', position: 20, targetElement: '_Item' }
  ]
  @UI.hidden: true
  ReportUuid;

  @UI: { lineItem:       [ { position: 10 } ],
         identification: [ { position: 10 } ],
         selectionField: [ { position: 10 } ] }
  EmployeeId;

  @UI: { lineItem:       [ { position: 20 } ],
         identification: [ { position: 20 } ] }
  Description;

  @UI: { lineItem:       [ { position: 30 } ],
         identification: [ { position: 30 } ] }
  TotalAmount;

  @UI.identification: [ { position: 40 } ]
  CurrencyCode;

  @UI: { lineItem:       [ { position: 40 },
                           { type: #FOR_ACTION, dataAction: 'approve', label: 'Approve' },
                           { type: #FOR_ACTION, dataAction: 'reject',  label: 'Reject' } ],
         identification: [ { position: 50 },
                           { type: #FOR_ACTION, dataAction: 'submit',  label: 'Submit' },
                           { type: #FOR_ACTION, dataAction: 'approve', label: 'Approve' },
                           { type: #FOR_ACTION, dataAction: 'reject',  label: 'Reject' } ],
         selectionField: [ { position: 20 } ] }
  Status;

  @UI.identification: [ { position: 60 } ]
  RejectReason;
}

This file has three important parts:

  • @UI.facet: The sections of the object page. The first shows the fields marked with identification as a form; the second adds the item table with #LINEITEM_REFERENCE and targetElement: '_Item'. The table's columns come from the item entity's own lineItem annotations.
  • #FOR_ACTION: Places actions as buttons. Those inside lineItem appear in the list (the manager can approve straight from the list without opening the report), those inside identification appear in the object page header. Whether a button is enabled is decided by feature control, not by the annotation.
  • @UI.headerInfo: The object page title and the name of the object type. Nothing extra is needed for the currency to appear next to the amount column; @Semantics.amount.currencyCode comes from the interface layer.

The item side is shorter:

@Metadata.layer: #CORE
@UI.headerInfo: {
  typeName: 'Expense Item',
  typeNamePlural: 'Expense Items',
  title: { type: #STANDARD, value: 'ExpenseType' }
}
annotate entity ZC_ExpenseItem with
{
  @UI.facet: [
    { id: 'Item', purpose: #STANDARD, type: #IDENTIFICATION_REFERENCE,
      label: 'Item', position: 10 }
  ]
  @UI.hidden: true
  ItemUuid;

  @UI.hidden: true
  ReportUuid;

  @UI: { lineItem:       [ { position: 10 } ],
         identification: [ { position: 10 } ] }
  ExpenseDate;

  @UI: { lineItem:       [ { position: 20 } ],
         identification: [ { position: 20 } ] }
  ExpenseType;

  @UI: { lineItem:       [ { position: 30 } ],
         identification: [ { position: 30 } ] }
  Amount;

  @UI: { lineItem:       [ { position: 40 } ],
         identification: [ { position: 40 } ] }
  Note;
}

In the preview you now see a list of reports that can be filtered by employee and status; opening a report shows the general information and the item table; a total that updates by itself as items are added; buttons that are enabled or disabled depending on the status; and a dialog asking for a reason when "Reject" is pressed. Not a single line of JavaScript was written for any of this.

Step 7: Read Authorization for Items

I covered the header's read authorization with pfcg_auth in the guide. The point often missed in header–item objects is that the item entity needs a role as well. If an entity marked #CHECK has no role at all, reading it is not restricted. Because the service also exposes the items as a separate entity, someone who cannot see the header could query the items directly.

The solution is not to rewrite the item's conditions but to inherit them from the header:

@EndUserText.label: 'Expense item read authorization'
@MappingRole: true
define role ZR_EXPENSEITEM {
  grant select on ZR_ExpenseItem
    where inheriting conditions from entity ZR_ExpenseReport
      replacing { root with _Report };
}

replacing { root with _Report } applies the conditions of the header role to the item via the _Report association. When the header's rule changes, the item's rule changes with it. The projection views get one role each along the same lines, inheriting from the corresponding interface entity.

Creation Order in ADT

Because the objects depend on each other, the order matters. This is the order I follow:

  1. Package and message class.
  2. The two tables; activate them.
  3. The two interface views. The composition and the to-parent association point to each other, so activate them together.
  4. The abstract entity ZD_RejectReason.
  5. The behavior definition. The quick fix on the warnings in the draft table lines generates the two draft tables, and the one on the class name generates the behavior pool. Missing methods are also added with a quick fix.
  6. The code in the behavior pool; activate it together with the BDEF.
  7. Projection views, metadata extensions and the projection BDEF.
  8. Access control roles.
  9. Service definition, OData V4 - UI service binding, publishing and preview.

Depending on your release, ADT also offers a wizard that starts from a table and generates most of this skeleton in one step. For your first project I still recommend building the objects by hand: skipping past generated code you do not understand means not knowing what to do at the first error.

Test Scenarios

Before handing over the project I verify it manually in the preview and cover the most critical rules with ABAP Unit. The minimum list for this project:

Test scenarios and expected results for the expense report project
ScenarioExpected
New report with two items of 100 and 250Total 350; updated on screen after each item
Change the currency in the headerThe currency of all items changes too
Save with an item whose amount is 0The report is not saved; the amount field of that item is marked as faulty
Submit a report without itemsError message; status unchanged
A report in draftSubmit, Approve and Reject disabled
A submitted reportSubmit disabled; Approve and Reject enabled
Reject without a reasonError message; status unchanged
Delete an item, save, submitThe total sent for approval matches the remaining items
Query the item entity directly with an unauthorized userItems of reports whose header the user cannot see are not returned

The first candidates for automated tests are validate_amount and the empty-report rule in submit; both are pure business rules whose outcome can be verified through failed / reported. I explained how to break dependencies with test doubles in the ABAP Unit article.

What I Deliberately Left Out

Keeping an example project small means saying clearly what is missing. A real expense app would also have:

A readable report number

The UUID key is not shown to users. In a real project you add a separate report number drawn from a number range; if the number must be gap-free for legal reasons, it has to be assigned during the save phase (late numbering).

Locking submitted reports against editing

In this example a submitted report can still be edited. In a real project, editing (Edit) and adding items are also tied to the status with feature control.

Currency conversion, attachments and a real approval workflow

Receipts in foreign currencies, attaching receipt images, determining the manager automatically, substitution and multi-level approval. Once the approval flow grows, it is better managed by a workflow solution than by actions.

Posting to accounting and notifications

Creating an accounting document from an approved report should go through APIs released by SAP; you do not write to the tables directly. RAP business events can be used to notify other systems at approval time; I covered that in the SAP integration scenarios article.

Common Mistakes in Header–Item Objects

Forgetting with draft

Writing association _Item { create; } in a draft-enabled object and omitting with draft keeps the items out of the draft flow. The error usually surfaces when the first item is added. The same rule applies to use association in the projection.

Not adding item validations to Prepare

It is common to list only header validations in the header's Prepare. If child validations such as ExpenseItem~validateAmount are not added there, item errors do not show up during the draft phase.

Calculating the total separately everywhere

Calculating the total separately in the item determination, in an action and in a report ends with one of them being forgotten when the rule changes. The calculation should live in a single internal action that the other places call.

Not enabling side effects in the projection

Defining side effects in the BDEF but not writing use side effects in the projection BDEF leaves the screen unaware of the change. The value is correct in the background, but the user cannot see it until they refresh — and it comes back to you as a "wrong total" ticket.

Not writing a role for the item entity

The header role does not protect the items. If no role inheriting from the header is written for the items, the item entity exposed by the service can be read without any authorization restriction.

Mixing up draft and active records

Matching an item to its header by ReportUuid alone can mix up the draft and active versions of the same report. Include %is_draft in the match or carry keys with %tky.

Conclusion

Moving from a single-entity RAP example to a real project actually involves few new concepts: composition, parent-dependent locking and authorization, internal actions, side effects and metadata extensions. The real work is putting them in the right place. Rules belong in the interface layer, calculations in one place, the screen description in a separate file, and authorization inherited from the header.

This skeleton is not specific to expense reports: most header–item processes, such as purchase pre-requests, maintenance notifications or contract approvals, are built with the same pattern. When you try it on your own system, merge the objects with the skeleton ADT generates and walk through the test scenario table manually first. You can find the scope I offer on the development side on the SAP development services page.

Get support with architecture, code reviews or team coaching while building your first RAP project.

Request an Initial Consultation
← Back to Blog

Contact

Get in touch for your projects.