Transaction Shortcuts — reusable one-click posting templates

The whole shortcut module reduces to one idea:

A shortcut is a saved posting recipe — "from account → to account, this kind of entry" — that an operator fires with just an amount. Submitting a shortcut dispatches to the matching entry service (cashbook / journal / note) with the saved accounts and transaction sides, posts the entry immediately (status = POSTED), and records a lightweight ShortcutTransaction log row. The shortcut itself holds no money and writes no GL legs — the underlying entry service does (so all the balancing/validation rules of that sub-module apply unchanged).

Source: BE src/modules/finance/shortcut · Admin src/modules/finance/shortcut (+ shortcut/order/ extension) + src/modules/finance/template (print templates) + src/pages/finance/shortcut/*

Related: _overview · cashbook · journal · note · transaction


1. Purpose & scope

Shortcuts let operators pre-configure a recurring posting (e.g. "Pay rent from Bank to Rent Expense", "Monthly depreciation journal", "Standard credit note for customer X") and then submit it repeatedly by entering only an amount (+ optional remark). Three entry kinds are supported by the core TransactionShortcut:

  • CashBookEntry — fires CashBookEntryService.addEntry (a payment/receipt voucher).
  • JournalEntry — fires JournalEntryService.addEntry (a 2-line journal).
  • NoteEntry — fires NoteEntryService.addEntry (a credit/debit note for a party).

Each submission is logged in finance_shortcut_transactions (the run history of a shortcut).

It does NOT:

  • Post anything itself — it delegates to the entry services, which own the GL legs, balance checks, tax legs, and fiscal-period validation.
  • Carry per-line tax / cost-center / analytics — the recipe is just from/to account + sides (+ note party). Anything richer must be built directly in the entry sub-module.
  • Cover Sales/Purchase orders in the core module. The admin shortcut UI also drives a separate OrderShortcut backend (inventory/order/shortcut) for order kinds — documented in §8 as the sibling, not part of finance/shortcut.

2. Data model

2.1 finance_transaction_shortcutsTransactionShortcut (the recipe)

shortcut/shortcut.schema.ts. Extends BaseSchema, soft-delete.

field type required description
name string yes Display name of the shortcut.
transactionKind AccountTransactionKind yes Which entry service to fire — CashBookEntry, JournalEntry, or NoteEntry (others not handled, see §9).
cashbookType CashBookEntryTypes no For cashbook shortcuts: PAYMENT or RECEIPT (defaults to PAYMENT at submit).
fromAccountId ObjectId no The "from" account (cashbook main / journal line 1).
fromTransactionType AccountTransactionTypes default CREDIT Side for the from leg.
toAccountId ObjectId no The "to" account (cashbook line / journal line 2 / note offset account).
toTransactionType AccountTransactionTypes default DEBIT Side for the to leg.
noteType NoteEntryTypes no For note shortcuts: CREDIT or DEBIT (defaults to CREDIT).
customerId ObjectId no For note shortcuts: the party (customer/supplier user id → passed as the note's accountId).
groupId ObjectId no Declared but unused in code (no reads).
remark string no Default remark/description/reason for the fired entry.
// shortcut/shortcut.schema.ts
@ApSchema({ collection: "finance_transaction_shortcuts" })
export class TransactionShortcut extends BaseSchema {
  name: string;                                  // required
  cashbookType: CashBookEntryTypes;              // PAYMENT | RECEIPT
  transactionKind: AccountTransactionKind;       // CashBookEntry | JournalEntry | NoteEntry
  fromAccountId: Types.ObjectId;
  fromTransactionType: AccountTransactionTypes = CREDIT;
  toAccountId: Types.ObjectId;
  toTransactionType: AccountTransactionTypes = DEBIT;
  groupId: Types.ObjectId;                       // unused
  noteType: NoteEntryTypes;                      // CREDIT | DEBIT
  customerId: Types.ObjectId;                    // note party (user id)
  remark: string;
}
// $lookupAccounts hydrates fromAccount / toAccount for display.

2.2 finance_shortcut_transactionsShortcutTransaction (the run log)

shortcut/shortcut-transaction.schema.ts. One row per submission.

field type description
shortcutId ObjectId which shortcut was fired
kind AccountTransactionKind copied from the shortcut's transactionKind
entryId ObjectId the _id of the entry that was created (cashbook/journal/note header)
ref string the created entry's ref (e.g. PV…, JN…, CNE…)
amount number amount submitted
remark string remark used
documentDate number submission timestamp (Date.now())

ShortcutTransaction is a log/audit trail of fires, not a GL leg. The actual postings live in finance_account_transactions via the entry service.


3. API surface

GraphQL (shortcut/shortcut.resolver.ts). NB: the resolver is @ApGqlAuthorize({ authNotRequired: true }) — see §9.

Operation Type Input Returns Audit
createTransactionShortcut Mutation CreateTransactionShortcutInput TransactionShortcut CREATE
updateTransactionShortcut Mutation _id, UpdateTransactionShortcutInput TransactionShortcut UPDATE
deleteTransactionShortcut Mutation _id Boolean DELETE
submitShortcutTransaction Mutation SubmitShortcutTransactionInput { shortcutId, amount, remark } Boolean STATUS_CHANGE
findOneTransactionShortcut Query TransactionShortcutQueryInput { keyword } TransactionShortcut
transactionShortcutPage Query TransactionShortcutPageInput { keyword, skip, take } TransactionShortcutPageResult
shortcutTransactions Query shortcutId [ShortcutTransaction]

Resolve-fields: fromAccount / toAccount (accountSvc.findById), customer (userSvc.findById).

Input DTO (shortcut/shortcut.dto.ts):

@InputType() class TransactionShortcutCommonInput {
  name!: string;
  cashbookType?: CashBookEntryTypes;
  transactionKind!: AccountTransactionKind;
  fromAccountId?: string;  fromTransactionType?: AccountTransactionTypes;
  toAccountId?: string;    toTransactionType?: AccountTransactionTypes;
  noteType?: NoteEntryTypes;
  customerId?: string;
  remark?: string;
}
class CreateTransactionShortcutInput extends TransactionShortcutCommonInput {}
class UpdateTransactionShortcutInput extends PartialType(TransactionShortcutCommonInput) {}

@InputType() class SubmitShortcutTransactionInput { shortcutId!: string; amount!: number; remark!: string; }

REST: none.


4. Business rules & calculations

4.1 Submit — dispatch by transactionKind (TransactionShortcutService.submitTransaction)

submitShortcutTransaction({ shortcutId, amount, remark })
   1. shortcut = findById(shortcutId)            → 404 if missing
   2. remark = input.remark ?? shortcut.remark;  documentDate = Date.now()
   3. switch (shortcut.transactionKind):
        CashBookEntry → cashBookEntrySvc.addEntry({
            type: shortcut.cashbookType || PAYMENT, accountId: fromAccountId,
            status: POSTED, documentDate, description: remark,
            transactions: [
              { amount, remark, accountId: fromAccountId, transactionType: fromTransactionType },
              { amount, remark, accountId: toAccountId,   transactionType: toTransactionType },
            ] })
        JournalEntry → journalEntrySvc.addEntry({
            status: POSTED, documentDate, description: remark,
            transactions: [ buildJournalLine(fromAccountId, fromTransactionType, amount, remark),
                            buildJournalLine(toAccountId,   toTransactionType,   amount, remark) ] })
        NoteEntry → noteEntrySvc.addEntry({
            type: shortcut.noteType || CREDIT, accountId: customerId, reason: remark,
            status: POSTED, documentDate, applyInvoice: false,
            transactions: [ { amount, remark, accountId: toAccountId } ] })
        default → 404 "Transaction kind not found"
   4. shortcutTransactionRepo.create({ shortcutId, kind, entryId: entry._id, ref: entry.ref,
                                       amount, remark, documentDate })

buildJournalLine maps a side to debit/credit columns:

isDebit = type === DEBIT;  → { accountId, type, debit: isDebit ? amount : 0, credit: isDebit ? 0 : amount }

Every fired entry is created POSTED and today-dated (Date.now()). The amount is the same on both legs; the balancing/validation is whatever the target entry service enforces (e.g. journal Σdr==Σcr, note party resolution, cashbook two-leg pairing). The shortcut adds no math of its own.

4.2 GL effect (by kind)

The shortcut's GL effect is exactly that of the entry it fires:

  • Cashbook shortcut → the cashbook two-leg posting (main fromAccount + line toAccount), direction per cashbookType and the saved sides. See cashbook §4.
  • Journal shortcut → a 2-line journal (from side + to side). See journal. The admin enforces the two sides differ (one DEBIT, one CREDIT) via Yup.
  • Note shortcut → a credit/debit note against customerId with toAccountId as the offset, applyInvoice: false. See note §4.

4.3 Transactionality (gotcha)

submitTransaction is not wrapped in withRetryTransaction and setSession is a no-op. The called entry services each open their own retry transaction; the ShortcutTransaction log write happens after and outside any shared session. So a shortcut submission is not atomic across the entry + the log row — if the log write fails, the entry is still posted. (Within the entry service the posting itself is atomic.)

4.4 CRUD

Create/update/delete are plain AbstractBaseService operations on the recipe row (delete is soft-delete). Editing a shortcut does not touch prior fired entries.


5. Permissions

  • Permission module exists: ApModules.SHORTCUTS = "shortcuts". However the resolver is annotated @ApGqlAuthorize({ authNotRequired: true }) and carries no per-mutation guard — so the shortcut operations are not gated by the standard finance guards (no @GuardPostedEntry, no @GuardLockedPeriod). The downstream entry services still run their own validateTransactionDate etc. when posting. Mutations carry @AuditMeta({ module: 'shortcut', collection: 'finance_transaction_shortcuts', … }). Flag authNotRequired: true as a hardening item for a rebuild (§9).

6. Flows

6.1 Create a shortcut (happy path)

  1. Admin opens Finance → Shortcuts (pages/finance/shortcut/index.tsx) → "Create".
  2. Picks Entry Type (transactionKind), which reveals the relevant fields:
    • Cashbook: cashbook type, from account + side, to account + side, remark.
    • Journal: from account + side, to account + side, description (sides must differ).
    • Note: customer/supplier, note type, account, reason.
  3. Submit → createTransactionShortcut. The recipe is saved (no posting).

6.2 Fire a shortcut

  1. From the shortcut table, open the transaction modal (components/transaction.tsx) — it shows the saved from/to accounts (or party + account for notes) read-only.
  2. Operator enters amount (+ remark) → submitShortcutTransaction.
  3. The matching entry is created POSTED; a ShortcutTransaction log row is recorded.
  4. shortcutTransactions(shortcutId) lists the run history.

6.3 Unhappy paths

  • Shortcut not foundNotFoundException.
  • Unsupported transactionKind (anything other than the three handled) → NotFoundException("Transaction kind not found").
  • Downstream failure (e.g. journal not balanced, note party has no account, locked period) → the entry service throws and rolls back its own posting; the log row is not written.

7. Admin UI

7.1 Routes & module files

  • Page: pages/finance/shortcut/index.tsx.
  • Module src/modules/finance/shortcut: context.tsx, model.ts, gql/{query,fragment}, page.tsx, and components/{create,table,transaction}.tsx.
  • Order extension src/modules/finance/shortcut/order/: context.tsx, gql.ts, model.ts — drives the sibling OrderShortcut backend for Sales/Purchase order kinds (§8).
  • Print templates src/modules/finance/template/entry.tsx (EntryTemplate) — a printable single/double-entry voucher layout used by the /templates/finance/entries/* PDF pages (cashbook, journal, note, contra, trade, payment). This is the "template" referenced in the brief: it is a presentation template for entries, not a posting recipe.

7.2 Context (useTransactionShortcutState)

saveTransactionShortcut (create-or-update), page/findOne, delete, submitShortcutTransaction, modal state. The order extension exposes a parallel useOrderShortcutState with saveOrderShortcut / raiseOrderFromShortcut.

7.3 Create form (components/create.tsx)

A single Formik form with transactionKind driving conditional fields. entryTypeOptions merges the three AccountTransactionKind recipe kinds and the OrderKindTypes (so the same form also creates order shortcuts, routed to saveOrderShortcut when isOrderKind(kind)). Yup enforces: name required; from/to account required for journal; to account + customer required for note; order lines valid + ≥1 for order kinds; remark required for cashbook; journal sides must differ.

7.4 Fire form (components/transaction.tsx)

Read-only summary of the recipe (from/to accounts, or party + account + note type) + an amount and remark field → submitShortcutTransaction.


8. Dependencies & integrations

  • cashbook / journal / note — the three entry services submitTransaction dispatches to. All GL effects flow through them.
  • accountfindById for resolve-fields; the entry services resolve account/party.
  • userfindById for the note party (customer resolve-field).
  • OrderShortcut (sibling, inventory/order/shortcut) — a separate backend module (order-shortcut.{schema,service,resolver,repository,dto}.ts) with its own createOrderShortcut / updateOrderShortcut / deleteOrderShortcut / raiseOrderFromShortcut mutations and orderShortcutPage query (see schema.gql). The admin shortcut UI presents both in one list/form, but on the backend they are two distinct modules. Order shortcuts hold item lines (OrderShortcutItem: lineType ITEM/CATEGORY/DESCRIPTION, item/account, rate, qty), a party, store, payment method/type, and raiseOrderFromShortcut materialises an actual order. Document fully under the inventory/order domain.

9. Gotchas & project-specific rules

  • submitTransaction is not transactional across entry + log. The entry service posts atomically, but the ShortcutTransaction log row is written afterwards, outside any shared session — a partial failure can leave a posted entry with no log row. Wrap both in one session for a rebuild.
  • Resolver is authNotRequired: true with no @Guard* decorators, despite the SHORTCUTS permission existing. Shortcut create/update/delete/submit bypass the standard finance auth/guards (downstream entry services still validate fiscal dates). Treat as a hardening gap.
  • Only 3 kinds handled by the core service (CashBookEntry, JournalEntry, NoteEntry); any other transactionKind throws at submit. The admin form offers order kinds too, but those route to the separate OrderShortcut backend, not submitShortcutTransaction.
  • Everything fires POSTED and today-dated. There is no SAVED/draft path and no way to back-date a shortcut submission — the document date is Date.now(). Don't use shortcuts for prior-period postings.
  • groupId is dead — declared on the schema, never read.
  • Note shortcut passes customerId as the note's accountId — which the note service expects to be a user id (it resolves to the party's debtor/creditor account). Consistent with note's party-id convention (see note §9).
  • "Template" is overloaded. finance/template (admin) = printable voucher layout (EntryTemplate), not a posting recipe. The posting recipe is the shortcut. Keep them distinct.