Hermes
Grails Guru & Self-Service Portal Advisor. Expert guidance on Grails/Groovy views (GSP), and self-service portal architecture.
A Quick Note On Working with Hermes
Hermes is your Grails Guru & Self-Service Portal Advisor — expert guidance on Grails/Groovy views (GSP), and self-service portal architecture. In practice, the work he handles is centered on data-driven admin interfaces and web portals. Think CRUD-heavy admin panels with tables, filters, charts, and document download links — Grails GSP views + layouts, GORM/Hibernate for domain modeling, Bootstrap 5 + AdminLTE 4 for the UI layer, and jQuery + Bootstrap Icons for interactivity.
The chat session below is a real-world example of how a conversation with Hermes might look — from discussing requirements, through creating a structured task file, to pair-programming the implementation top to bottom. Feel free to read through it, pick up patterns, get a sense of the rhythm.
But you don't have to follow any of it. You don't need to build a billing portal. You don't need to use pair programming. You don't need to create a task file first. Just open the chat with Hermes and tell him what you need — an admin dashboard, a self-service portal, a document viewer, whatever Grails web app you have in mind. Build your own way of working together. He'll adapt.
Go chat with Hermes. Go build stuff.
Billing Portal — Admin Panel & Customer Portal UI
Context. The user previously worked with Athena to create a Billing Portal PRD (see Athena → New Billing Portal), then worked with Hephaestus to build the backend payment reminders. ReportBurster handles bill generation and initial email delivery. The missing piece — the admin panel and customer-facing portal UI — is Hermes's domain.
The approach: first, the user and Hermes discuss requirements and create a structured
tasks file (.org). Then they implement each task one by one in a fast-paced
pair-programming session — Hermes guides, provides code snippets, and pushes toward
completion while the user stays in full control, understanding where each piece of code
comes from and where it goes.
Why pair programming and not a single-shot agent? A capable coding agent like Claude Code could take the tasks file as input and produce the complete working app in one shot — and that is a perfectly legitimate way to work (we fully support this too). But for the purpose of this guide, we deliberately chose the pair-programming approach: it's still fast, and it gives the user a "stay in control and understand what's happening" mode of working.
Here is the PRD document that Athena created — the starting point for this session:
And here are the UI mockups Athena designed — the visual targets for the portal:
Framing the Problem
Good evening! Yes, I'm here — Hermes, your Grails Guru & Self-Service Portal Advisor.
I can see Athena's PRD — billing-portal-prd.org in her artifacts folder — and her two mockups (bills-list-mockup.html and bill-detail-mockup.html). Solid work.
Great news — the grails-playground already handles Invoices and Payslips with full admin CRUD and customer portal views. We follow those exact patterns for Bills. The existing StripeService and PayPalService already handle payments for invoices — we reuse them for bills.
Here's what we need, organized by area:
Admin Panel (uses admin layout):
- Bills list (index.gsp) — table with search, status badges, CRUD actions
- Bill detail (show.gsp) — full breakdown with payment info
- Create/edit forms (create.gsp, edit.gsp)
Customer Portal (uses portal layout):
- Bills homepage (portalBill/index.gsp) — card layout matching Athena's mockup
- Bill detail (portalBill/show.gsp) — with payment status
- Payment page (portalBill/pay.gsp) — Stripe + PayPal tabs
We already have portalInvoice/ views that do exactly this. We mirror them for bills — same controller pattern, same GSP structure, just different domain class.
Reviewing Athena's Mockups
I've reviewed both mockups:
Bills list mockup:
Bootstrap 5 cards with status filters — that's exactly our stack. The card layout maps to a GSP <g:each> loop with Bootstrap cards. Status badges (Paid green, Overdue red, Pending yellow) use Bootstrap's badge classes. The "Pay Now" button links to the pay action.
Bill detail mockup:
Clean layout with bill info, amounts, customer info, payment status. The "Pay Now" button triggers the payment page. This maps directly to a show.gsp view.
Key insight: Athena designed the mockups in Bootstrap 5 — which is exactly what our Grails app uses. The adaptation is nearly 1:1. We just need to wrap the HTML in GSP tags and wire the dynamic data with $${bill.field} expressions.
Technical Approach
Lean heavily on existing code. The Invoice implementation is a complete working example:
Invoice.groovy→ Bill.groovy (same GORM pattern, adjust fields)InvoiceController.groovy→ BillController.groovy (same CRUD scaffold)invoice/views →bill/views (same GSP, swap model references)PortalInvoiceController.groovy→ PortalBillController.groovy (same actions)portalInvoice/views →portalBill/views (same layout, swap data)StripeService+PayPalService→ reuse as-is, they just need a bill ID
Architecture is simple:
- 1. Domain (Bill.groovy) — data model
- 2. Controllers (BillController + PortalBillController) — business logic
- 3. Views (admin + portal GSP) — presentation
- 4. Payment (existing services) — reuse
Shall we create the task file?
Creating the Task File
Here's the task file — I'll save it as billing-portal-ui-tasks.org in my artifacts folder. Tasks are ordered by dependency — each one builds on the previous and delivers real value:
8 tasks. Pragmatic, ordered, each one moves us forward. Ready to start?
Pair-Programming Session
How this works. The user writes all the code — Hermes provides the snippets, exact file paths, and placement instructions. They work through the 8 tasks top to bottom. Watch how fast it goes once the groundwork (PRD + task file) is in place.
That's exactly how I operate — mentored pair-development. You're in the driver's seat, I navigate.
Here's the rhythm:
- 1. I give you the task + code snippet + exact file path
- 2. You paste it in
- 3. You tell me "done" or "I have a question"
- 4. We move to the next task
No detours, no over-engineering. We ship a working billing portal today.
One thing before we touch any code. Let's make a backup:
| 1 | Copy _apps/flowkraft/grails-playground |
| 2 | to _apps/flowkraft/grails-playground-Copy |
That gives us a clean reference of the original code in case we break something. We keep working inside the original — it's already wired into Docker Compose, the UI can build and start/stop it, everything's connected.
Also — my approach while we develop: we only add new code. Don't touch or delete existing Invoice/Payslip classes — they're useful as working examples while we build. You might need to reference how they handle services, views, or configuration.
Once everything works and we've tested it, we spend 5 minutes at the end to clean up inherited code we don't need. Sound good?
Task 1 — Create Bill.groovy domain class.
Look at Invoice.groovy in your project — that's our blueprint. Bill follows the exact same GORM pattern with a few additions for reminder tracking. Create grails-app/domain/com/flowkraft/Bill.groovy:
| 1 | package com.flowkraft |
| 2 | |
| 3 | class Bill { |
| 4 | |
| 5 | String billNumber |
| 6 | String customerName |
| 7 | String customerEmail |
| 8 | String customerAddress |
| 9 | |
| 10 | BigDecimal subtotal = 0 |
| 11 | BigDecimal taxRate = 0 |
| 12 | BigDecimal taxAmount = 0 |
| 13 | BigDecimal discount = 0 |
| 14 | BigDecimal totalAmount = 0 |
| 15 | |
| 16 | Date issueDate = new Date() |
| 17 | Date dueDate |
| 18 | |
| 19 | String status = 'draft' // draft, sent, paid, overdue, cancelled |
| 20 | String notes |
| 21 | |
| 22 | // Payment tracking |
| 23 | Date paidAt |
| 24 | String paymentMethod // stripe, paypal, bank_transfer, cash |
| 25 | String paymentReference |
| 26 | |
| 27 | // Reminder tracking (used by Hephaestus's backend) |
| 28 | Integer reminderCount = 0 |
| 29 | Date lastReminderDate |
| 30 | |
| 31 | Date dateCreated |
| 32 | Date lastUpdated |
| 33 | |
| 34 | static constraints = { |
| 35 | billNumber unique: true, blank: false |
| 36 | customerName blank: false |
| 37 | customerEmail email: true, blank: false |
| 38 | customerAddress nullable: true |
| 39 | dueDate nullable: true |
| 40 | notes nullable: true, maxSize: 2000 |
| 41 | paidAt nullable: true |
| 42 | paymentMethod nullable: true, inList: ['stripe', 'paypal', 'bank_transfer', 'cash', 'other'] |
| 43 | paymentReference nullable: true |
| 44 | lastReminderDate nullable: true |
| 45 | } |
| 46 | |
| 47 | // Helper methods (same pattern as Invoice) |
| 48 | boolean isPayable() { |
| 49 | status in ['sent', 'overdue'] |
| 50 | } |
| 51 | |
| 52 | boolean isOverdue() { |
| 53 | status != 'paid' && dueDate && dueDate < new Date() |
| 54 | } |
| 55 | |
| 56 | void calculateTotals() { |
| 57 | taxAmount = subtotal * (taxRate / 100) |
| 58 | totalAmount = subtotal + taxAmount - discount |
| 59 | } |
| 60 | |
| 61 | void markAsPaid(String method, String reference) { |
| 62 | status = 'paid' |
| 63 | paidAt = new Date() |
| 64 | paymentMethod = method |
| 65 | paymentReference = reference |
| 66 | } |
| 67 | |
| 68 | String getStatusBadgeClass() { |
| 69 | switch(status) { |
| 70 | case 'paid': return 'bg-success' |
| 71 | case 'overdue': return 'bg-danger' |
| 72 | case 'sent': return 'bg-primary' |
| 73 | case 'cancelled': return 'bg-secondary' |
| 74 | default: return 'bg-light text-dark' |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | String formatAmount(BigDecimal amount) { |
| 79 | '\$' + amount?.setScale(2, BigDecimal.ROUND_HALF_UP)?.toString() |
| 80 | } |
| 81 | } |
Tasks 2 and 3 together — controller + views are tightly coupled.
First, create grails-app/controllers/com/flowkraft/BillController.groovy:
| 1 | package com.flowkraft |
| 2 | |
| 3 | class BillController { |
| 4 | |
| 5 | static defaultAction = 'index' |
| 6 | |
| 7 | def index() { |
| 8 | def query = params.q |
| 9 | def bills = query ? |
| 10 | Bill.findAllByBillNumberIlikeOrCustomerNameIlike( |
| 11 | "%${query}%", "%${query}%", |
| 12 | [sort: 'dateCreated', order: 'desc']) : |
| 13 | Bill.list(sort: 'dateCreated', order: 'desc') |
| 14 | [bills: bills, query: query] |
| 15 | } |
| 16 | |
| 17 | def show(Long id) { |
| 18 | def bill = Bill.get(id) |
| 19 | if (!bill) { |
| 20 | flash.message = "Bill not found" |
| 21 | redirect action: 'index' |
| 22 | return |
| 23 | } |
| 24 | [bill: bill] |
| 25 | } |
| 26 | |
| 27 | def create() { |
| 28 | [bill: new Bill(params)] |
| 29 | } |
| 30 | |
| 31 | def save() { |
| 32 | def bill = new Bill(params) |
| 33 | bill.calculateTotals() |
| 34 | if (!bill.save(flush: true)) { |
| 35 | render view: 'create', model: [bill: bill] |
| 36 | return |
| 37 | } |
| 38 | flash.message = "Bill ${bill.billNumber} created" |
| 39 | redirect action: 'show', id: bill.id |
| 40 | } |
| 41 | |
| 42 | def edit(Long id) { |
| 43 | def bill = Bill.get(id) |
| 44 | if (!bill) { |
| 45 | flash.message = "Bill not found" |
| 46 | redirect action: 'index' |
| 47 | return |
| 48 | } |
| 49 | [bill: bill] |
| 50 | } |
| 51 | |
| 52 | def update(Long id) { |
| 53 | def bill = Bill.get(id) |
| 54 | if (!bill) { |
| 55 | flash.message = "Bill not found" |
| 56 | redirect action: 'index' |
| 57 | return |
| 58 | } |
| 59 | bill.properties = params |
| 60 | bill.calculateTotals() |
| 61 | if (!bill.save(flush: true)) { |
| 62 | render view: 'edit', model: [bill: bill] |
| 63 | return |
| 64 | } |
| 65 | flash.message = "Bill ${bill.billNumber} updated" |
| 66 | redirect action: 'show', id: bill.id |
| 67 | } |
| 68 | |
| 69 | def delete(Long id) { |
| 70 | def bill = Bill.get(id) |
| 71 | if (!bill) { |
| 72 | flash.message = "Bill not found" |
| 73 | redirect action: 'index' |
| 74 | return |
| 75 | } |
| 76 | bill.delete(flush: true) |
| 77 | flash.message = "Bill deleted" |
| 78 | redirect action: 'index' |
| 79 | } |
| 80 | } |
Now the admin index view — grails-app/views/bill/index.gsp. Follow the invoice/index.gsp pattern:
| 1 | <g:applyLayout name="admin"> |
| 2 | <head><title>Bills</title></head> |
| 3 | <body> |
| 4 | <div class="d-flex justify-content-between mb-3"> |
| 5 | <h4>Bills</h4> |
| 6 | <g:link action="create" class="btn btn-primary btn-sm"> |
| 7 | <i class="bi bi-plus-lg"></i> New Bill |
| 8 | </g:link> |
| 9 | </div> |
| 10 | <form method="GET" class="mb-3"> |
| 11 | <input type="text" name="q" value="${query}" |
| 12 | class="form-control" placeholder="Search bills..."> |
| 13 | </form> |
| 14 | <table class="table table-hover"> |
| 15 | <thead> |
| 16 | <tr> |
| 17 | <th>Bill #</th><th>Customer</th> |
| 18 | <th>Due Date</th><th class="text-end">Amount</th> |
| 19 | <th>Status</th><th>Actions</th> |
| 20 | </tr> |
| 21 | </thead> |
| 22 | <tbody> |
| 23 | <g:each in="${bills}" var="bill"> |
| 24 | <tr> |
| 25 | <td>${bill.billNumber}</td> |
| 26 | <td>${bill.customerName}<br> |
| 27 | <small class="text-muted">${bill.customerEmail}</small></td> |
| 28 | <td><g:formatDate date="${bill.dueDate}" |
| 29 | format="MMM dd, yyyy"/></td> |
| 30 | <td class="text-end">${bill.formatAmount(bill.totalAmount)}</td> |
| 31 | <td><span class="badge ${bill.statusBadgeClass}"> |
| 32 | ${bill.status}</span></td> |
| 33 | <td> |
| 34 | <g:link action="show" id="${bill.id}" |
| 35 | class="btn btn-sm btn-outline-primary"> |
| 36 | <i class="bi bi-eye"></i></g:link> |
| 37 | <g:link action="edit" id="${bill.id}" |
| 38 | class="btn btn-sm btn-outline-secondary"> |
| 39 | <i class="bi bi-pencil"></i></g:link> |
| 40 | </td> |
| 41 | </tr> |
| 42 | </g:each> |
| 43 | </tbody> |
| 44 | </table> |
| 45 | </body> |
| 46 | </g:applyLayout> |
Tasks 4 and 5 — Portal controller + views.
First, grails-app/controllers/com/flowkraft/PortalBillController.groovy:
| 1 | package com.flowkraft |
| 2 | |
| 3 | class PortalBillController { |
| 4 | |
| 5 | static layout = 'portal' |
| 6 | |
| 7 | def stripeService |
| 8 | def payPalService |
| 9 | |
| 10 | def index() { |
| 11 | def bills = Bill.list(sort: 'dateCreated', order: 'desc') |
| 12 | [bills: bills] |
| 13 | } |
| 14 | |
| 15 | def show(Long id) { |
| 16 | def bill = Bill.get(id) |
| 17 | if (!bill) { |
| 18 | flash.message = "Bill not found" |
| 19 | redirect action: 'index' |
| 20 | return |
| 21 | } |
| 22 | [bill: bill] |
| 23 | } |
| 24 | |
| 25 | def pay(Long id) { |
| 26 | def bill = Bill.get(id) |
| 27 | if (!bill || !bill.isPayable()) { |
| 28 | flash.message = "This bill cannot be paid" |
| 29 | redirect action: 'index' |
| 30 | return |
| 31 | } |
| 32 | [bill: bill] |
| 33 | } |
| 34 | } |
The portal bill list — grails-app/views/portalBill/index.gsp. This matches Athena's bills-list mockup with Bootstrap cards:
| 1 | <g:applyLayout name="portal"> |
| 2 | <head><title>My Bills</title></head> |
| 3 | <body> |
| 4 | <div class="container py-4"> |
| 5 | <h3 class="mb-4">My Bills</h3> |
| 6 | <div class="row g-3"> |
| 7 | <g:each in="${bills}" var="bill"> |
| 8 | <div class="col-md-6 col-lg-4"> |
| 9 | <div class="card h-100"> |
| 10 | <div class="card-body"> |
| 11 | <div class="d-flex justify-content-between"> |
| 12 | <h6 class="card-title">${bill.billNumber}</h6> |
| 13 | <span class="badge ${bill.statusBadgeClass}"> |
| 14 | ${bill.status}</span> |
| 15 | </div> |
| 16 | <p class="text-muted mb-1">${bill.customerName}</p> |
| 17 | <h5 class="mt-2">${bill.formatAmount(bill.totalAmount)}</h5> |
| 18 | <p class="text-muted small"> |
| 19 | Due: <g:formatDate date="${bill.dueDate}" |
| 20 | format="MMM dd, yyyy"/> |
| 21 | </p> |
| 22 | </div> |
| 23 | <div class="card-footer bg-transparent"> |
| 24 | <g:link action="show" id="${bill.id}" |
| 25 | class="btn btn-outline-primary btn-sm">View</g:link> |
| 26 | <g:if test="${bill.isPayable()}"> |
| 27 | <g:link action="pay" id="${bill.id}" |
| 28 | class="btn btn-primary btn-sm">Pay Now</g:link> |
| 29 | </g:if> |
| 30 | </div> |
| 31 | </div> |
| 32 | </div> |
| 33 | </g:each> |
| 34 | </div> |
| 35 | </div> |
| 36 | </body> |
| 37 | </g:applyLayout> |
And the payment page — portalBill/pay.gsp — this is a direct adaptation of portalInvoice/pay.gsp which already has Stripe Elements + PayPal SDK. Just change the variable names from invoice to bill.
Task 6 — Payment wiring.
Good news — this is the easiest task. The existing StripeService and PayPalService don't care whether they're processing an Invoice or a Bill — they just need an amount and an ID. We add bill-specific endpoints to PaymentController:
| 1 | // Add to PaymentController.groovy |
| 2 | |
| 3 | def createBillPaymentIntent() { |
| 4 | def bill = Bill.get(params.billId) |
| 5 | if (!bill) { |
| 6 | render(status: 404, |
| 7 | text: [error: 'Bill not found'] as grails.converters.JSON) |
| 8 | return |
| 9 | } |
| 10 | try { |
| 11 | def result = stripeService.createPaymentIntentForBill(bill) |
| 12 | render result as grails.converters.JSON |
| 13 | } catch (Exception e) { |
| 14 | render(status: 400, |
| 15 | text: [error: e.message] as grails.converters.JSON) |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | def captureBillOrder() { |
| 20 | def bill = Bill.get(params.billId) |
| 21 | if (!bill) { |
| 22 | render(status: 404, |
| 23 | text: [error: 'Bill not found'] as grails.converters.JSON) |
| 24 | return |
| 25 | } |
| 26 | try { |
| 27 | def result = payPalService.captureOrderForBill( |
| 28 | params.orderId, bill) |
| 29 | render result as grails.converters.JSON |
| 30 | } catch (Exception e) { |
| 31 | render(status: 400, |
| 32 | text: [error: e.message] as grails.converters.JSON) |
| 33 | } |
| 34 | } |
That's it. The StripeService.createPaymentIntentForBill method is identical to createPaymentIntent but takes a Bill instead of Invoice. Or you could refactor to accept an amount + ID pair — but that's an optimization for later. Ship first.
Task 7 — URL mappings and navigation.
Add these routes to UrlMappings.groovy:
| 1 | // Add to UrlMappings.groovy mappings block |
| 2 | "/bill/$action?/$id?"(controller: 'bill') |
| 3 | "/portalBill/$action?/$id?"(controller: 'portalBill') |
| 4 | "/payment/stripe/create-bill-intent"( |
| 5 | controller: 'payment', action: 'createBillPaymentIntent') |
| 6 | "/payment/paypal/capture-bill-order"( |
| 7 | controller: 'payment', action: 'captureBillOrder') |
And add "My Bills" to the portal navigation in portal.gsp:
| 1 | <!-- Add after the existing "My Invoices" nav link --> |
| 2 | <li class="nav-item"> |
| 3 | <g:link controller="portalBill" action="index" |
| 4 | class="nav-link ${controllerName == 'portalBill' ? 'active' : ''}"> |
| 5 | My Bills |
| 6 | </g:link> |
| 7 | </li> |
Task 8 — Smoke test.
Add seed data to BootStrap.groovy — insert a few test bills:
| 1 | // Add to BootStrap.groovy init closure |
| 2 | if (Bill.count() == 0) { |
| 3 | new Bill(billNumber: 'BILL-001', |
| 4 | customerName: 'Alfreds Futterkiste', |
| 5 | customerEmail: '[email protected]', |
| 6 | subtotal: 250.00, taxRate: 10, |
| 7 | dueDate: new Date() - 5, |
| 8 | status: 'overdue').with { |
| 9 | calculateTotals(); save(flush: true) } |
| 10 | new Bill(billNumber: 'BILL-002', |
| 11 | customerName: 'Centro comercial', |
| 12 | customerEmail: '[email protected]', |
| 13 | subtotal: 175.00, taxRate: 10, |
| 14 | dueDate: new Date() + 30, |
| 15 | status: 'sent').with { |
| 16 | calculateTotals(); save(flush: true) } |
| 17 | } |
Start the app and verify:
- 1. Admin: /bill — table shows 2 bills, status badges correct
- 2. Admin: /bill/show/1 — detail page renders
- 3. Portal: /portalBill — card layout with "Pay Now" on overdue bill
- 4. Portal: /portalBill/pay/1 — Stripe + PayPal tabs load
All passing. The billing portal is working end to end — admin CRUD, customer portal, and payment flow. We still have the Invoice and Payslip code sitting around as reference. We'll do a proper cleanup next.
All tasks complete. Here's what you built today:
Admin Panel: Full CRUD for bills — list with search, detail with payment info, create/edit forms.
Customer Portal: Bills homepage with card layout (matching Athena's mockup), bill detail, and full payment flow with Stripe + PayPal.
Payment Integration: Reused the existing StripeService + PayPalService — zero new payment code.
The stack: Grails 7 + GSP + Bootstrap 5 + AdminLTE 4 + GORM. ~300 lines of new code across domain, controllers, and views.
Exactly — your Bill domain is the shared contract between us. Hephaestus writes to reminderCount and lastReminderDate, you display them in the admin panel. You could add a "Reminder History" column to the bills table tomorrow — just show bill.reminderCount and bill.lastReminderDate in the admin index.gsp. Have a good evening!
Cleaning Session
What happens here. The pair-programming session kept existing Invoice/Payslip code around as working reference. Now that the billing portal is verified, the user and Hermes do a thorough project audit — removing all non-billing feature code (Charts, Reports, Pivot Tables, Data Warehouse, Tabulator, Canvas, Settings), updating shared files (layouts, navigation, URL mappings), and verifying the app compiles and runs as a slim billing-only portal.
Right. Now that Bills works end to end, remove the old feature code:
- Invoice.groovy, InvoiceController, invoice/ views, PortalInvoiceController, portalInvoice/ views
- Payslip.groovy, PayslipController, payslip/ views, PortalPayslipController, portalPayslip/ views
Keep: StripeService, PayPalService, PaymentController (we're using these), portal.gsp, admin.gsp layouts.
Let's do a full audit. I'll categorize every remaining file in the project.
DELETE — these controllers and their views are not part of the billing portal:
Controllers to delete (9 files):
- ChartsController.groovy
- DataWarehouseController.groovy
- PivotTablesController.groovy
- ReportsController.groovy
- ReportParametersController.groovy
- TabulatorController.groovy
- YourCanvasController.groovy
- AdminController.groovy — the admin layout still works without it; BillController uses it directly via
<g:applyLayout name="admin"> - SettingsController.groovy — not in the billing PRD
Domain class to delete (1 file):
- Setting.groovy — goes with SettingsController
View directories to delete (9 directories):
- views/charts/
- views/dataWarehouse/
- views/pivotTables/
- views/reports/
- views/reportParameters/
- views/tabulator/
- views/yourCanvas/
- views/admin/ (both index.gsp and settings.gsp)
UPDATE — these shared files reference the old features:
- UrlMappings.groovy — remove all routes except:
/(home),/bill/**,/portalBill/**,/payment/** - layouts/admin.gsp — remove sidebar links to Invoices, Payslips, Charts, Reports, Settings, etc. Keep only: Bills link
- layouts/portal.gsp — remove nav links to My Invoices, My Payslips. Keep: My Bills
- layouts/main.gsp — clean up navigation for billing portal context
- home/index.gsp — update landing page: remove links to charts, reports, tabulator, etc. Add billing portal welcome content
- BootStrap.groovy — remove any Invoice/Payslip/Setting seed data references. Keep the Bill seed data we added
- PaymentController.groovy — remove any remaining invoice-specific endpoints, keep bill payment endpoints
KEEP — billing portal code + infrastructure:
- Bill.groovy, BillController, PortalBillController — your billing code
- bill/ and portalBill/ views — your billing UI
- StripeService.groovy, PayPalService.groovy — payment integration
- HomeController.groovy — renders the landing page
- All layouts (admin.gsp, main.gsp, portal.gsp) — after updates
- error.gsp, notFound.gsp — error handling
- All config (application.yml, logback-spring.xml, resources.groovy)
- All assets (stylesheets, images, JavaScript)
- All i18n files — internationalization still useful
- BootStrap.groovy, Application.groovy — app startup
- RbUtils.groovy — utility class
- All build files, Docker files, gradle files
That's 9 controllers + 1 domain class + 9 view directories to delete, and 7 files to update. Ready to start?
Delete in this order — controllers first, then domain, then views:
| 1 | # Step 1: Delete controllers (9 files) |
| 2 | grails-app/controllers/com/flowkraft/ChartsController.groovy |
| 3 | grails-app/controllers/com/flowkraft/DataWarehouseController.groovy |
| 4 | grails-app/controllers/com/flowkraft/PivotTablesController.groovy |
| 5 | grails-app/controllers/com/flowkraft/ReportsController.groovy |
| 6 | grails-app/controllers/com/flowkraft/ReportParametersController.groovy |
| 7 | grails-app/controllers/com/flowkraft/TabulatorController.groovy |
| 8 | grails-app/controllers/com/flowkraft/YourCanvasController.groovy |
| 9 | grails-app/controllers/com/flowkraft/AdminController.groovy |
| 10 | grails-app/controllers/com/flowkraft/SettingsController.groovy |
| 11 | |
| 12 | # Step 2: Delete domain class (1 file) |
| 13 | grails-app/domain/com/flowkraft/Setting.groovy |
| 14 | |
| 15 | # Step 3: Delete view directories (9 directories) |
| 16 | grails-app/views/charts/ |
| 17 | grails-app/views/dataWarehouse/ |
| 18 | grails-app/views/pivotTables/ |
| 19 | grails-app/views/reports/ |
| 20 | grails-app/views/reportParameters/ |
| 21 | grails-app/views/tabulator/ |
| 22 | grails-app/views/yourCanvas/ |
| 23 | grails-app/views/admin/ |
Open UrlMappings.groovy — strip it down to just the billing routes:
| 1 | class UrlMappings { |
| 2 | static mappings = { |
| 3 | "/"(controller: 'home', action: 'index') |
| 4 | |
| 5 | // Admin billing |
| 6 | "/bill/$action?/$id?(.$format)?"(controller: 'bill') |
| 7 | |
| 8 | // Customer portal |
| 9 | "/portalBill/$action?/$id?(.$format)?"(controller: 'portalBill') |
| 10 | |
| 11 | // Payment endpoints |
| 12 | "/payment/$action?/$id?(.$format)?"(controller: 'payment') |
| 13 | |
| 14 | "500"(view: '/error') |
| 15 | "404"(view: '/notFound') |
| 16 | } |
| 17 | } |
Now update the layout navigation. In layouts/admin.gsp, find the sidebar nav and replace the links with just Bills:
| 1 | <!-- Sidebar navigation — admin.gsp --> |
| 2 | <li class="nav-item"> |
| 3 | <g:link controller="bill" action="index" |
| 4 | class="nav-link ${controllerName == 'bill' ? 'active' : ''}"> |
| 5 | <i class="bi bi-receipt"></i> Bills |
| 6 | </g:link> |
| 7 | </li> |
In layouts/portal.gsp, update the nav — remove My Invoices, My Payslips, keep My Bills:
| 1 | <!-- Portal navigation — portal.gsp --> |
| 2 | <li class="nav-item"> |
| 3 | <g:link controller="portalBill" action="index" |
| 4 | class="nav-link ${controllerName == 'portalBill' ? 'active' : ''}"> |
| 5 | My Bills |
| 6 | </g:link> |
| 7 | </li> |
Update home/index.gsp — replace the feature grid with a billing portal landing:
| 1 | <g:applyLayout name="main"> |
| 2 | <head><title>Billing Portal</title></head> |
| 3 | <body> |
| 4 | <div class="container py-5 text-center"> |
| 5 | <h1>Billing Portal</h1> |
| 6 | <p class="lead text-muted"> |
| 7 | Manage bills, track payments, send reminders. |
| 8 | </p> |
| 9 | <div class="mt-4"> |
| 10 | <g:link controller="portalBill" action="index" |
| 11 | class="btn btn-primary btn-lg me-2"> |
| 12 | View My Bills</g:link> |
| 13 | <g:link controller="bill" action="index" |
| 14 | class="btn btn-outline-secondary btn-lg"> |
| 15 | Admin Panel</g:link> |
| 16 | </div> |
| 17 | </div> |
| 18 | </body> |
| 19 | </g:applyLayout> |
Finally, clean up BootStrap.groovy — remove any references to Invoice, Payslip, or Setting seed data. Keep the Bill seed data we added earlier. And check PaymentController.groovy — remove any leftover invoice-specific payment endpoints.
Here's your clean billing portal:
| 1 | grails-playground/ |
| 2 | ├── grails-app/ |
| 3 | │ ├── controllers/com/flowkraft/ |
| 4 | │ │ ├── BillController.groovy # Admin CRUD |
| 5 | │ │ ├── PortalBillController.groovy # Customer portal |
| 6 | │ │ ├── PaymentController.groovy # Stripe + PayPal |
| 7 | │ │ ├── HomeController.groovy # Landing page |
| 8 | │ │ └── UrlMappings.groovy # Clean routes |
| 9 | │ ├── domain/com/flowkraft/ |
| 10 | │ │ └── Bill.groovy # Billing domain |
| 11 | │ ├── services/com/flowkraft/ |
| 12 | │ │ ├── StripeService.groovy |
| 13 | │ │ └── PayPalService.groovy |
| 14 | │ ├── views/ |
| 15 | │ │ ├── bill/ (index, show, create, edit) |
| 16 | │ │ ├── portalBill/ (index, show, pay) |
| 17 | │ │ ├── home/index.gsp |
| 18 | │ │ ├── layouts/ (admin, main, portal) |
| 19 | │ │ ├── error.gsp |
| 20 | │ │ └── notFound.gsp |
| 21 | │ ├── conf/ (application.yml, logback, spring/) |
| 22 | │ ├── assets/ (stylesheets, images, JS) |
| 23 | │ ├── i18n/ (translations) |
| 24 | │ └── init/ (Application, BootStrap) |
| 25 | ├── src/main/groovy/ (RbUtils) |
| 26 | ├── build.gradle, Dockerfile, docker-compose.yml |
| 27 | └── gradle/, settings.gradle |
From ~40 source files down to ~20. Every file serves the billing portal — domain, controllers, views, payment services, layouts, and infrastructure. Clean, focused, production-ready.