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 lightweightShortcutTransactionlog 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— firesCashBookEntryService.addEntry(a payment/receipt voucher).JournalEntry— firesJournalEntryService.addEntry(a 2-line journal).NoteEntry— firesNoteEntryService.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 offinance/shortcut.
2. Data model
2.1 finance_transaction_shortcuts — TransactionShortcut (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_transactions — ShortcutTransaction (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()) |
ShortcutTransactionis a log/audit trail of fires, not a GL leg. The actual postings live infinance_account_transactionsvia 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+ linetoAccount), direction percashbookTypeand 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
customerIdwithtoAccountIdas 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 ownvalidateTransactionDateetc. when posting. Mutations carry@AuditMeta({ module: 'shortcut', collection: 'finance_transaction_shortcuts', … }). FlagauthNotRequired: trueas a hardening item for a rebuild (§9).
6. Flows
6.1 Create a shortcut (happy path)
- Admin opens Finance → Shortcuts (
pages/finance/shortcut/index.tsx) → "Create". - 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.
- Submit →
createTransactionShortcut. The recipe is saved (no posting).
6.2 Fire a shortcut
- 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. - Operator enters amount (+ remark) →
submitShortcutTransaction. - The matching entry is created POSTED; a
ShortcutTransactionlog row is recorded. shortcutTransactions(shortcutId)lists the run history.
6.3 Unhappy paths
- Shortcut not found →
NotFoundException. - 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, andcomponents/{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
submitTransactiondispatches to. All GL effects flow through them. - account —
findByIdfor resolve-fields; the entry services resolve account/party. - user —
findByIdfor the note party (customerresolve-field). - OrderShortcut (sibling,
inventory/order/shortcut) — a separate backend module (order-shortcut.{schema,service,resolver,repository,dto}.ts) with its owncreateOrderShortcut/updateOrderShortcut/deleteOrderShortcut/raiseOrderFromShortcutmutations andorderShortcutPagequery (seeschema.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, andraiseOrderFromShortcutmaterialises an actual order. Document fully under the inventory/order domain.
9. Gotchas & project-specific rules
submitTransactionis not transactional across entry + log. The entry service posts atomically, but theShortcutTransactionlog 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: truewith no@Guard*decorators, despite theSHORTCUTSpermission 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 othertransactionKindthrows at submit. The admin form offers order kinds too, but those route to the separate OrderShortcut backend, notsubmitShortcutTransaction. - 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. groupIdis dead — declared on the schema, never read.- Note shortcut passes
customerIdas the note'saccountId— 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.