DEV Community

Cover image for Designing an RCTI Workflow: Validation, Money, Snapshots, and PDFs
Jack Ma
Jack Ma

Posted on

Designing an RCTI Workflow: Validation, Money, Snapshots, and PDFs

An invoice form looks simple until the software has to preserve what was true when the document was issued.

That problem is especially visible with an Australian recipient-created tax invoice (RCTI). Unlike an ordinary supplier invoice, an RCTI is issued by the recipient of the supply. The workflow also depends on conditions outside the PDF itself, including the parties' GST status and an appropriate agreement.

The Australian Taxation Office explains that an RCTI must make its purpose clear, identify the relevant parties, and state that GST shown is payable by the supplier. Its guidance also describes written-agreement and record-retention requirements. See the ATO's GSTR 2000/10 guidance and RCTI reference form before implementing a production workflow.

This article is about the software architecture behind that workflow. It is not tax or legal advice, and generating a structurally complete PDF does not establish that a business is entitled to issue an RCTI.

Start with a workflow, not a PDF template

A weak implementation models an RCTI as a collection of text boxes followed by a Download PDF button:

Form input -> HTML template -> PDF
Enter fullscreen mode Exit fullscreen mode

A more useful model treats generation as a state transition:

Draft
  -> validate parties
  -> validate commercial inputs
  -> calculate totals
  -> confirm agreement context
  -> render document
  -> store immutable snapshot
  -> allow owner-authorised download
Enter fullscreen mode Exit fullscreen mode

This distinction matters because a saved business profile is mutable, while an issued document is historical evidence. If a supplier changes its trading name or address next month, last month's invoice should not silently change with it.

1. Keep legal entity data separate from display data

Business software often overloads one businessName field. In practice, a user may recognise a supplier by a trading name while the document also needs the correct legal entity and ABN.

A party model can keep those concepts explicit:

type PartyInput = {
  entityName: string;
  businessName: string;
  abn: string;
  address: string;
};

type PartySnapshot = {
  entityName: string;
  businessName: string;
  abn: string;
  address: string;
};
Enter fullscreen mode Exit fullscreen mode

Do not use an ABN lookup response as an automatic compliance decision. A lookup can help the user detect a mistyped number or retrieve an entity name, but the application still needs to distinguish several outcomes:

type AbnLookupResult =
  | { status: "found"; entityName: string; businessNames: string[] }
  | { status: "inactive" }
  | { status: "not_found" }
  | { status: "unavailable" };
Enter fullscreen mode Exit fullscreen mode

These states should produce different messages:

  • inactive: explain that the ABN is not currently active and ask the user to verify the party.
  • not_found: ask the user to check all 11 digits.
  • unavailable: allow manual entry where appropriate and explain that verification could not be completed.

Treating all three as “Invalid ABN” is inaccurate and gives the user no recovery path.

The ATO's own GST data-testing guidance treats ABN and GST-registration checks as meaningful controls for RCTIs, rather than mere formatting concerns. That is a good reason to make verification status visible in the domain model instead of burying it inside an input component.

2. Represent money in minor units

JavaScript floating-point arithmetic is a poor storage model for financial totals:

0.1 + 0.2; // 0.30000000000000004
Enter fullscreen mode Exit fullscreen mode

Store committed amounts as integer cents. If the interface accepts decimal dollars, parse once at the boundary and calculate with integers afterward.

type LineItem = {
  description: string;
  quantity: number;
  unitPriceCents: number;
  discountBasisPoints: number;
  gstApplicable: boolean;
};

type CalculatedLine = LineItem & {
  netCents: number;
  gstCents: number;
  totalCents: number;
};

function calculateLine(item: LineItem): CalculatedLine {
  const grossCents = Math.round(item.quantity * item.unitPriceCents);
  const discountCents = Math.round(
    (grossCents * item.discountBasisPoints) / 10_000,
  );
  const netCents = grossCents - discountCents;
  const gstCents = item.gstApplicable ? Math.round(netCents / 10) : 0;

  return {
    ...item,
    netCents,
    gstCents,
    totalCents: netCents + gstCents,
  };
}
Enter fullscreen mode Exit fullscreen mode

There are two design decisions worth making explicit:

  1. Rounding boundary: decide whether GST is rounded per line or after aggregation, then use the same rule in the UI, API, stored record, and PDF.
  2. GST applicability: do not infer it solely from the existence of an ABN. ABN status, GST registration, the nature of the supply, and the parties' circumstances are separate facts.

The example above demonstrates arithmetic, not a universal tax determination.

3. Separate validation into layers

A single schema cannot answer every question. Split validation according to what the system can actually know.

Shape validation

This is deterministic and belongs at the input boundary:

import { z } from "zod";

const partySchema = z.object({
  entityName: z.string().trim().min(1),
  businessName: z.string().trim().min(1),
  abn: z
    .string()
    .transform((value) => value.replace(/\s/g, ""))
    .pipe(z.string().regex(/^\d{11}$/)),
  address: z.string().trim().min(1),
});
Enter fullscreen mode Exit fullscreen mode

External verification

This includes ABN lookup and service availability. It can time out, so it should return a recoverable state rather than pretending to be synchronous schema validation.

Workflow validation

This answers questions such as:

  • Is there at least one supply line?
  • Is the issue date sensible?
  • Is the invoice number unique under this business's numbering policy?
  • Has the user confirmed the agreement context?
  • Are the parties being used in the correct recipient/supplier roles?

Eligibility confirmation

Some conditions cannot be proven by form fields. The software can request an acknowledgement, show the relevant rules, and preserve the acknowledgement, but it should not claim to have replaced professional judgement or the user's legal obligations.

4. Freeze an immutable issuance snapshot

Do not generate document history by joining an old invoice to the current party table.

Consider this schema:

type IssuedDocument = {
  id: string;
  userId: string;
  kind: "rcti" | "agreement";
  issueDate: string;
  documentNumber: string | null;
  recipientSnapshot: PartySnapshot;
  supplierSnapshot: PartySnapshot;
  lineItemsSnapshot: CalculatedLine[];
  totalAmountCents: number;
  pdfObjectKey: string;
  createdAt: string;
};
Enter fullscreen mode Exit fullscreen mode

The recipientSnapshot, supplierSnapshot, and lineItemsSnapshot fields capture the issued state. Foreign keys to saved parties can still be useful for filtering or convenience, but they should not be the only historical source.

This gives the system two different truths:

  • Profile truth: the latest reusable details for future documents.
  • Document truth: the details used for one past issuance.

That distinction also makes corrections safer. Instead of editing history in place, create a replacement or adjustment workflow and retain the relationship between records.

5. Make PDF storage and database writes behave like one operation

Object storage and a relational database usually cannot share a transaction. A common failure mode looks like this:

  1. Upload PDF successfully.
  2. Fail to insert the database record.
  3. Leave an orphaned private file forever.

A compensating action handles the partial failure:

async function storeIssuedDocument(input: CreateDocumentInput) {
  const id = crypto.randomUUID();
  const key = `documents/${input.userId}/${id}.pdf`;

  await objectStorage.put(key, input.pdfBytes, {
    contentType: "application/pdf",
  });

  try {
    await database.transaction(async (tx) => {
      await assertDocumentLimit(tx, input.userId);
      await tx.insertIssuedDocument({
        id,
        ...input.snapshot,
        pdfObjectKey: key,
      });
    });
  } catch (error) {
    await objectStorage.delete(key).catch(() => undefined);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

If usage limits or permissions are involved, check before the upload for fast feedback and check again inside the transaction to prevent a race between concurrent requests.

6. Authorise the download, not just the page

Hiding another user's document in the UI is not access control. The download endpoint must include ownership in the query:

async function getDocumentForDownload(userId: string, documentId: string) {
  const document = await db.query.issuedDocuments.findFirst({
    where: and(
      eq(issuedDocuments.id, documentId),
      eq(issuedDocuments.userId, userId),
    ),
  });

  if (!document) return null;
  return objectStorage.get(document.pdfObjectKey);
}
Enter fullscreen mode Exit fullscreen mode

For administrative access, use a separate route that checks an explicit permission before loading the record. Do not weaken the user-scoped function with an optional isAdmin flag.

Other useful controls include:

  • private object storage rather than guessable public URLs;
  • a safe Content-Disposition filename;
  • an audit event for administrative downloads;
  • rate limits appropriate to the account and endpoint;
  • generic not-found responses that do not reveal whether another user's document exists.

7. Prefer warnings over arbitrary blocking

Some suspicious situations are not necessarily invalid. Two RCTIs for the same supplier on the same day may be a duplicate, or they may represent two legitimate supplies.

An advisory duplicate check is often better than a hard uniqueness constraint:

const possibleDuplicate = await hasInvoice({
  userId,
  supplierId,
  issueDate,
});

if (possibleDuplicate) {
  return {
    requiresConfirmation: true,
    message: "An RCTI already exists for this supplier on this date.",
  };
}
Enter fullscreen mode Exit fullscreen mode

Block only when the system knows the operation is disallowed. Warn when the system has detected risk but lacks enough context to decide.

A practical pre-issuance checklist

Before enabling the final PDF action, verify that the workflow has addressed:

  • recipient and supplier roles are unambiguous;
  • legal entity names, business names, ABNs, and addresses are separate fields;
  • external lookup failure is distinguishable from a negative result;
  • GST calculations use one documented rounding policy;
  • line items and totals are recalculated server-side or otherwise integrity-checked;
  • agreement context is captured before issuance;
  • the generated document is clearly identified as an RCTI;
  • issued party and line-item data are stored as immutable snapshots;
  • partial object-storage failures are cleaned up;
  • downloads are scoped to the authenticated owner;
  • administrative access uses an explicit permission path;
  • corrections preserve rather than rewrite history.

Where this architecture is used

I applied these patterns while building RCTI Generator, a web application for preparing Australian RCTI invoices and RCTI agreements. The application includes ABN-assisted party entry, optional GST calculations, PDF generation, saved business/payee profiles, immutable document history, and owner-scoped downloads.

The tool helps structure and retain the information, but users remain responsible for confirming that their arrangement and supplies satisfy the current ATO requirements.

Closing thought

The hardest part of document software is rarely drawing the document. It is preserving the relationship between user input, external facts, calculation rules, authorisation, and historical state.

If the architecture treats the PDF as the last output of a controlled workflow—not the workflow itself—the product becomes easier to audit, safer to extend, and less likely to rewrite history accidentally.


Disclosure: I am involved in building RCTI Generator, the product mentioned in this article.

AI assistance disclosure: AI tools assisted with outlining, drafting, and editing. The implementation details and cited sources were reviewed against the project code and official ATO materials before publication.

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

The snapshot approach is the part that really stands out to me. A lot of document systems get the PDF generation right but accidentally treat the current profile data as the source of truth for historical documents, which can create some nasty problems later.

I also like the separation between schema validation, external verification, and workflow validation. Those are very different failure modes, and trying to force them into one validation layer usually makes the system harder to reason about.

The object-storage/database failure case is another one that's easy to overlook. The compensating cleanup is simple, but it's exactly the kind of edge case that matters once documents become business records.

Good example of treating an invoice as an issued business event rather than just another rendered page. Curious how you're handling versioning/replacements when an issued document needs to be corrected without changing the original snapshot.