DEV Community

Roberto Luna
Roberto Luna

Posted on

Automatic Payment Receipts & History API for PlayaMXCRM

Automatic Payment Receipts & History API for PlayaMXCRM

TL;DR: Added a fully‑typed receipt generation endpoint with token‑based URLs, fixed date handling, and exposed a tenant/owner‑specific payment history. The changes touch the API layer, unit tests, and the React UI, turning a static PDF link into a reliable, searchable receipt service.


The Problem

Our rental‑management portal needed real‑time, per‑tenant payment receipts that could be opened from the UI and stored in a history view. The existing implementation only generated a PDF on demand with a hard‑coded token and displayed dates with timezone offsets, causing:

  • Broken links – the token was sometimes undefined (entry?.receiptToken typo).
  • Incorrect dates – receipts showed the server’s UTC midnight instead of the tenant’s local day, leading to confusion in the timeline component.
  • No historical view – tenants and owners could not see past receipts, forcing manual email look‑ups.

The symptom in the UI was a 404 when clicking “Open receipt”, and the API tests (recibos-e-historial.test.ts) failed with mismatched date objects.


What I Tried First

My initial fix was a quick patch in apps/web/src/app/income/[id]/page.tsx:

// before
if (entry?.receiptToken) {
  window.open(`/recibo/${entry.receiptToke`);
}
Enter fullscreen mode Exit fullscreen mode

I added a guard and a fallback URL, hoping the missing token was the only issue. The UI stopped throwing a 404, but the underlying problems persisted:

  • The token generation still produced non‑URL‑safe strings.
  • Dates remained offset because new Date() was used directly in the timeline.
  • No endpoint existed to list historical receipts, so the UI could not render a history list.

The test suite still reported failures around date handling and missing receipt history.


The Implementation

1. Token Generation & URL Helper (receipts.ts)

I introduced a deterministic, URL‑safe token generator and a helper to build the receipt URL.

// apps/api/src/finance/receipts.ts
import { randomBytes } from "crypto";

/** Generate a URL‑safe token (18 bytes → 24 chars) */
export function newReceiptToken(): string {
  return randomBytes(18).toString("base64url");
}

/** Build the public receipt URL */
export function receiptUrl(token: string): string {
  return `${process.env.WEB_URL}/recibo/${token}`;
}
Enter fullscreen mode Exit fullscreen mode

Why this change?

randomBytes(...).toString("base64url") guarantees no +, /, or = characters that break URLs. The helper centralises URL construction, making future domain changes trivial.

2. Date Normalisation (receipts.ts)

The receipt now stores calendar day (midnight UTC) instead of a full timestamp. I added a utility comment and adjusted the model:

/** Día de calendario (YYYY-MM-DD) – stored as midnight UTC */
export interface Receipt {
  token: string;
  amount: number;
  issuedAt: string; // e.g. "2026-10-13"
}
Enter fullscreen mode Exit fullscreen mode

When creating a receipt, we strip the time component:

const issuedAt = new Date().toISOString().split("T")[0]; // "2026-10-13"
Enter fullscreen mode Exit fullscreen mode

3. API Endpoint & Service (finance.controller.ts & finance.service.ts)

A new POST /receipts endpoint creates a receipt and returns the URL:

// apps/api/src/finance/finance.controller.ts
@Post("receipts")
async createReceipt(@Body() dto: CreateReceiptDto) {
  const receipt = await this.financeService.generateReceipt(dto);
  return { url: receiptUrl(receipt.token) };
}
Enter fullscreen mode Exit fullscreen mode

The service handles token creation and persistence:

// apps/api/src/finance/finance.service.ts
async generateReceipt(dto: CreateReceiptDto): Promise<Receipt> {
  const token = newReceiptToken();
  const receipt: Receipt = {
    token,
    amount: dto.amount,
    issuedAt: new Date().toISOString().split("T")[0],
  };
  await this.db.receipts.insert(receipt);
  return receipt;
}
Enter fullscreen mode Exit fullscreen mode

4. Historical Endpoint (timeline.controller.ts)

To expose a tenant/owner‑specific receipt list, I added a GET route that queries by user ID and orders by issuedAt descending.

// apps/api/src/timeline/timeline.controller.ts
@Get("receipts/:userId")
async getReceiptHistory(@Param("userId") userId: string) {
  return this.db.receipts.find({ userId }).sort({ issuedAt: -1 });
}
Enter fullscreen mode Exit fullscreen mode

5. Unit Tests (recibos-e-historial.test.ts)

The test suite now validates token generation, URL formation, and date handling:

// apps/api/src/__tests__/recibos-e-historial.test.ts
describe("Fechas del recibo (día de calendario)", () => {
  it("stores issuedAt as YYYY-MM-DD", async () => {
    const receipt = await financeService.generateReceipt({ amount: 1200 });
    expect(receipt.issuedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
  });
});
Enter fullscreen mode Exit fullscreen mode

I also added a new test for the history endpoint:

it("returns receipts ordered by issuedAt desc", async () => {
  const history = await timelineController.getReceiptHistory("tenant123");
  expect(history[0].issuedAt >= history[1].issuedAt).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

6. UI Adjustments

a. Opening a receipt (income/[id]/page.tsx)

// apps/web/src/app/income/[id]/page.tsx
async function openReceipt() {
  if (entry?.receiptToken) {
    const url = `/recibo/${entry.receiptToken}`;
    window.open(url, "_blank", "noopener");
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the token is guaranteed to exist, and the URL is built correctly.

b. Timeline component (_components/Timeline.tsx)

I added a comment and a conditional to display dates correctly:

// apps/web/src/app/_components/Timeline.tsx
// Fechas capturadas como día (medianoche UTC) se muestran tal cual; las que
// traen hora real, en hora de Cancún, se convierten.
function formatDate(dateStr: string) {
  return dateStr.includes("T")
    ? new Date(dateStr).toLocaleString("es-MX", { timeZone: "America/Cancun" })
    : dateStr; // already YYYY-MM-DD
}
Enter fullscreen mode Exit fullscreen mode

c. Receipt page (recibo/[token]/page.tsx)

The page now fetches receipt data by token and renders a clean view:

// apps/web/src/app/recibo/[token]/page.tsx
export default async function ReceiptPage({ params }: { params: { token: string } }) {
  const receipt = await api.get<Receipt>(`/api/receipts/${params.token}`);
  return (
    <section>
      <h1>Recibo #{receipt.token}</h1>
      <p>Monto: ${receipt.amount}</p>
      <p>Fecha: {receipt.issuedAt}</p>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

7. Documentation (CLAUDE.md)

I updated the keep‑alive docs to note the new /api/wake endpoint, but that change is orthogonal to the receipt flow.


Key Takeaway

**Never assume token strings are URL‑safe; always generate them with a base64url scheme and centralise


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-10-13

#playadev #buildinpublic

Top comments (0)