When Vibecoding Ships as Accounting Software

I spent an hour stress-testing a Nigerian accounting SaaS marketed as "all your operations, one intelligent suite." What I found was a textbook case of vibecoding: software that looks polished on the surface but crumbles the moment you step off the happy path.

I am not naming the product. The goal here is not to embarrass a founder. It is to document exactly what happens when AI-generated code ships as production business software without the engineering discipline that makes it actually work.

First Impressions: The Vibe Is Strong

The landing page hits every SaaS template checkbox. Gradient hero section. Emoji greeting. Feature cards with rounded corners. WhatsApp chat widget. Pricing tiers that look algorithmically generated, running from a free tier through four paid plans: ₦0, ₦3,900, ₦8,300, ₦12,600, and ₦55,800.

The dashboard greets you with "Good morning, John 👋" and perfectly rounded metrics. It feels like real software.

It is not.

The Stack: Legitimate Tools, Illegitimate Depth

Under the hood: Laravel 10+ backend, Vue 3 with <script setup> syntax, Inertia.js for SPA navigation, and Vite for builds. Not a bad stack. The problem is what sits on top of it.

The API architecture reveals itself through Inertia's JSON responses. Every page navigation returns a payload with three key pieces:

  • "component" — tells Vue which component to mount
  • "props" — server-fetched page data
  • "sharedProps" — globally injected on every request

The shared props include auth, planFeatures, contact, branding, and impersonator. The contact object contains address lines, phone number, WhatsApp number, and a map query. The planFeatures object contains 30+ boolean flags. None of this changes during a session, yet it travels with every XHR request.

This is not API design. This is a server vomiting state because no one implemented client-side caching or scoped prop sharing.

The invoices response for a list of two records includes full tenant objects with 15+ fields, nested tax_breakdown arrays, and customer relations. The backend uses Eloquent with ->with('customer') for eager loading, but when a customer is deleted it returns null without a ->withDefault() fallback. The frontend then executes invoice.customer.display_name and the entire Vue component tree crashes. No optional chaining. No null checks. No error boundaries anywhere in the component tree.

The Bugs: A Timeline of Collapse

Minute 5: Feature Gate Desync

I signed up for a free trial and immediately upgraded to the highest tier, Enterprise at ₦55,800 per month. The subscription page confirmed the switch with a green toast.

I navigated to Quotations, a feature available on the Basic tier at ₦3,900 per month, and hit a paywall:

"Quotations & estimates isn't on your plan yet. Upgrade to Basic for ₦3,900/mo."

The system was recommending I downgrade to Basic while I was on Enterprise. A hard refresh fixed it. The feature gate logic is checking a different field than the subscription status, or caching the previous plan, or hardcoding free tier as the default condition. State invalidation is broken and the fix is accidental.

Minute 15: Email Theater

I created a quotation for a test customer with email ai.slop@slops.com. Clicked Save and Send. Toast: "Quotation EST-1001 sent and emailed to ai.slop@slops.com."

The email arrived in my own tempmail inbox, addressed to me, the account owner. The customer email field is either UI decoration or the mail queue is not configured to route to the customer address at all. The "sent and emailed" toast fires regardless of where the email actually went. There is no delivery verification, no queue status, no failure handling.

Minute 25: Payment as Mutation

I converted the quotation to invoice INV-1002 for ₦2,032,610. Approved it. Clicked "Record payment" for ₦203,970. Status changed to "Partial."

The Activity log showed this:

"after": {
  "status": "partial",
  "amount_paid": "203970.0000"
},
"before": {
  "status": "sent",
  "amount_paid": "0.0000"
}

This is not a transaction. This is a JSON row update.

No journal entry is visible. No bank account is credited. No debit to cash, no credit to accounts receivable. The double-entry scaffolding exists in the database schema. Journal entry IDs exist in the table definitions. They are not being populated. The payment is a number increment on the invoice record, which is exactly what you would get from vibecoding a payment form without understanding what payment means in the context of accounting software.

The expense creation flow elsewhere in the app does show a Journal Entry Preview sidebar with proper Dr/Cr lines. That component exists. It is just absent here, where it matters most.

Minute 35: The Killshot

I deleted the test customer "Testing a AI slop." The customer had a partially paid invoice: INV-1002, ₦2,032,610 total, ₦203,970 paid. The deletion succeeded with a green toast: "Customer removed."

I navigated to Invoices.

White screen while browser showed this:

TypeError: Cannot read properties of null (reading 'display_name')

The invoice list component fetches all invoices for the tenant, includes the orphaned record (customer_id: 2056, customer: null), and crashes on mount before rendering a single row. I navigated to Quotations. White screen. Same error. I opened the specific invoice URL /invoices/38 directly. White screen.

I created a new customer and a new invoice, INV-1003, to test whether a clean record would bypass the crash. The invoice list still whitescreens. The component crashes before rendering anything because the corrupted record is in the dataset and the crash happens before any conditional rendering logic can run.

I logged out. Cleared cookies. Logged back in.

Still bricked.

The dashboard still shows ₦1.8M in Outstanding A/R. It aggregates directly from the invoices table, not from a ledger. The ghost invoice attached to a deleted customer contributes to the metric while being completely unviewable. The numbers look right. They mean nothing.

There is no recovery path. The data is server-side and permanent. The only fix is a database intervention to either restore the deleted customer or null out the invoice's customer reference in a way the frontend can handle. Neither is something an ordinary user can do.

Minute 50: The Public Link Paradox

The API response for INV-1003 included a public_url field: a tokenized public invoice link. I opened it in an incognito window with no session or cookies.

It rendered perfectly. Customer name, line items, totals, payment status, everything. A fully functional, correctly rendered public invoice page.

The unauthenticated public path renders invoices correctly. The authenticated internal dashboard cannot load the invoice list at all because of a null reference crash on a deleted customer record.

The route that requires no login has better error handling than the route that requires authentication and an active subscription.

The Architecture Problem

This is not a no-code tool or a Supabase direct-access project. It is a full Laravel backend with journal entry tables, foreign key definitions, and double-entry scaffolding. The database has the shape of rigor.

It has none of the substance.

No referential integrity constraint preventing deletion of a customer with active invoices. No ->withDefault() on the Eloquent relationship to return a safe placeholder instead of null. No optional chaining (customer?.display_name) in the Vue component. No error boundary to catch the crash before it propagates up the component tree. No recovery path for when data enters a corrupted state.

The activity log stores JSON diffs of mutated records, not immutable transactions. It is append-only in structure but the underlying data it references is fully mutable and deletable. You can read the history of changes to a record and then delete the record the history references.

The difference between a database schema that looks like accounting software and accounting software is the business logic that enforces what the schema implies. The schema says invoices belong to customers. The application does not enforce that relationship at write time, does not handle its absence at read time, and has no recovery mechanism when the relationship breaks. All three failures are present simultaneously.

Who Is This For?

Not businesses that need accounting software.

The target is solo founders and early-stage operators who judge software by UI quality and have not used production accounting tools before. The local pricing and WhatsApp widget add credibility in that market. But the product cannot survive contact with actual business operations. Deleting a customer is not an edge case. It is a foreseeable user action that requires a foreseeable response. Cascading a deletion into a permanently bricked invoice module is not a bug. It is evidence that the failure mode was never considered.

The Lesson

Vibecoding produces functional prototypes, not production systems.

AI can generate CRUD controllers, Vue components, migration files, and Eloquent relationships. It cannot generate the institutional knowledge that prevents a deleted customer from destroying an entire module. It cannot generate the paranoia that makes you ask "what happens if this relation is null?" at every point where relational data is accessed. It cannot generate the QA discipline that finds these bugs before users do. It cannot generate the understanding that a payment in accounting software is a double-entry transaction, not a status field update.

The gap between software that looks like it works and software that works is filled by engineering judgment. That judgment comes from building systems that have failed in production, understanding exactly why they failed, and designing the next one to handle those failures explicitly.

The vibe was strong. The code was not.

And somewhere right now, a small business owner probably has a permanently bricked invoice module, a dashboard showing ₦1.8M in receivables that do not exist, and no way to fix it without direct database access they will never have.

Comments

Popular Posts

Building Event Reliability at Monesize Core

RecruitX: From Reconnaissance to Remote Code Execution

God Never Wrote a Book: A Nigerian Agnostic's Case