This package is arranged as a simple monorepo for production deployment:
frontend/-> deploy to Vercel as a Next.js projectbackend/-> deploy to Railway as a Laravel API service
- Frontend: Vercel
- Backend API: Railway
- Database: Railway MySQL or PostgreSQL service
Vercel supports deploying a project from a monorepo by selecting the app directory as the project's Root Directory. Create one Vercel project for frontend/. Railway supports config-as-code via railway.json; deploy the backend/ directory as the Laravel service. Source Source
Create a Railway project from this repository and set the service root to backend/.
Set these in Railway:
APP_NAME=CharpsDev
APP_ENV=production
APP_DEBUG=false
APP_URL=https://YOUR-RAILWAY-BACKEND-DOMAIN
DB_CONNECTION=mysql
DB_HOST=YOUR_RAILWAY_DB_HOST
DB_PORT=3306
DB_DATABASE=YOUR_RAILWAY_DB_NAME
DB_USERNAME=YOUR_RAILWAY_DB_USER
DB_PASSWORD=YOUR_RAILWAY_DB_PASSWORD
CACHE_STORE=database
QUEUE_CONNECTION=database
SESSION_DRIVER=database
MAIL_MAILER=log
PAYSTACK_PUBLIC_KEY=pk_live_or_test_xxx
PAYSTACK_SECRET_KEY=sk_live_or_test_xxx
PAYSTACK_PAYMENT_URL=https://api.paystack.co- Railway will use
backend/railway.json - run one successful deploy
- if needed, open a Railway shell and run
php artisan migrate --force - optionally run
php artisan db:seed --forceonly for non-production demo data
The health endpoint is /up, and the deploy config uses that path for health checks. Source
Create a Vercel project from the same repository and set the Root Directory to frontend/. Source
Set these in Vercel:
NEXT_PUBLIC_APP_NAME=CharpsDev
NEXT_PUBLIC_API_URL=https://YOUR-RAILWAY-BACKEND-DOMAIN/apiThen deploy.
- Open the Vercel site
- confirm
/loginloads - verify login works
- verify
/dashboardloads after login - verify API calls succeed against the Railway backend
- verify admin login and
/admin/dashboard - verify Paystack callback/webhook URLs point to the Railway domain
- This app uses bearer-token auth from the frontend to the Laravel API.
- The frontend route guard is implemented in
frontend/proxy.ts. - The backend is configured to return JSON 401 responses for unauthenticated API requests.
- For production, use a managed MySQL/PostgreSQL service instead of SQLite.
- Deploy Railway backend
- Configure DB + app env vars
- Run migrations
- Confirm
/upand/api/login - Deploy Vercel frontend
- Set
NEXT_PUBLIC_API_URL - Smoke test login, dashboard, wallet, orders, notifications, profile, admin
The Next.js frontend uses the following stack for data fetching, state, UI, and route protection:
frontend/lib/queryClient.ts— sharedQueryClientinstance (retry/staleTime defaults).frontend/lib/queryKeys.ts— centralized query key factory (queryKeys.me,queryKeys.wallet, etc.) so cache invalidation stays consistent across hooks.frontend/hooks/queries/*.ts— one file per domain, each exportinguseXQuery/useXMutationhooks that wrapapi(axios) calls:useAuthQueries.ts(login/register/me/forgot-password/reset-password)useProfileQuery.ts,useWalletQueries.ts,useOrdersQueries.ts,useServicesQuery.ts,useNotificationsQueries.ts,useAdminQueries.ts
frontend/components/providers/QueryProvider.tsx— mountsQueryClientProvider+ React Query Devtools (dev only), wired intoAppProviders.tsx.- Mutations expose
isPending/variables, used across admin/dashboard tables to show a per-row busy state instead of a single global spinner.
frontend/store/authStore.ts— replaces the old React Context (AuthContext, now removed). Holdstoken/user, uses thepersistmiddleware to survive reloads, and exposes ahasHydratedflag so hooks can wait for hydration before firing queries.frontend/store/uiStore.ts— plain store for UI-only state (mobile sidebar open/close).frontend/hooks/useAuth.ts— composed hook combining the auth store with theuseMeQuery/login/logout mutations; this is the single entry point pages/components use for auth state (useAuth()), instead of importing the store directly.frontend/components/providers/AuthBootstrap.tsx— on app start, rehydrates the store and subscribes to a global 401 handler (lib/api.ts→onUnauthorized) that clears the session and redirects to/loginwhen any request comes back unauthorized.
frontend/app/globals.css— defines the full@themedesign-token block (--color-primary,--color-card,--color-border,--radius-*, etc.) consumed by every@base-ui/react-based primitive infrontend/components/ui/(button,card,avatar,toast,dropdown-menu,table,input, plus the newly addedbadgeandskeleton).frontend/components/layout/AppLayout.tsx— responsive Sidebar (desktop) / off-canvas drawer (mobile, viauiStore) + Topbar shell used by both the dashboard and admin route groups.frontend/components/common/StateBlock.tsx— sharedLoadingBlock/ErrorBlock/EmptyBlock, plusStatCardsSkeleton/TableSkeletonskeleton loaders used while queries are pending.- Known gap:
--font-geist-sansis referenced in the theme but not yet wired to anext/fontloader inapp/layout.tsx, so text currently falls back to the next font in the stack (Segoe UI) rather than Geist.
- Server/edge:
frontend/proxy.ts(Next.js 16's middleware convention) reads thecharpsdev_tokencookie to gate all protected prefixes (/dashboard,/services,/wallet,/orders,/notifications,/profile,/admin) and thecharpsdev_rolecookie to additionally redirect authenticated non-admins away from/admin/*to/dashboard. Authenticated users are redirected away from the auth pages (/login,/register, etc.). - Client:
frontend/components/auth/ProtectedRoute.tsx(auth-only) andfrontend/components/admin/AdminGuard.tsx(admin-only, nested insideProtectedRouteinapp/admin/layout.tsx) provide a client-side fallback/loading state on top of the edge redirect. - Both cookies are written by
frontend/lib/cookies.tswhenever the auth store's token/user change, so the edge and the client always agree on auth/role state.
- Next.js 16 removed the
next lintsubcommand, and the project has noeslint.config.(js|mjs|cjs)(ESLint v9 flat config), sonpm run lint/npx eslint .currently do not run. This predates the changes above and is left as follow-up debt — either add a flat config or switch to a different lint runner.
- Railway 500 errors (fixed): root causes were (1) missing
pdo_pgsql/pgsqlPHP extensions in the container image and (2) a missingAPP_KEY. Both are resolved inbackend/composer.jsonand Railway env vars. - Railway GitHub source link (fixed): the service's GitHub source connection had gone stale at the platform data layer (not just a stuck webhook), so new commits weren't deploying. Reconnected via Railway's API; new commits now deploy normally.
- Admin flow (verified end-to-end): promote a user to admin by setting
is_admin = trueon theusersrow (auser:make-adminArtisan command exists inbackend/app/Console/Commands/MakeUserAdmin.phpfor this). Confirmed pre-promotion/api/admin/*returns 403 and post-promotion returns 200 with real data. - Vercel "Git Author Verification" block (fixed): Vercel's Hobby plan blocks deploys when a commit's author doesn't match the GitHub identity connected to the Vercel project. Fixed by correcting the local git identity to the connected GitHub account (
Clearkess <Clearkess@users.noreply.github.com>) and rewriting the mismatched commits' author/committer. Going forward, always confirmgit config user.name/user.emailmatch the GitHub account linked to Vercel before committing. - Favicon 404 (fixed): the frontend had no favicon/icon files at all. Added
frontend/app/favicon.ico,frontend/app/icon.png, andfrontend/app/apple-icon.png— Next.js App Router auto-detects these files underapp/and injects the correct<link rel="icon">/<link rel="apple-touch-icon">tags on every page, no code changes required. - Frontend production-readiness pass: adopted TanStack Query for data fetching/caching, Zustand for global state (replacing React Context), a from-scratch Tailwind v4
@themetoken system (the existing@base-ui/reactprimitives referenced tokens that were never defined, so they were rendering unstyled), and role-based/admin/*gating infrontend/proxy.ts. See section 6 above for details. - Quick-wins pass (this change):
- Paystack payment flow (fixed):
PAYSTACK_SECRET_KEY/PAYSTACK_PUBLIC_KEYwere unset (null) on Railway, causing Paystack's API to reject every/api/payment/initializecall withsecret_key_invalid. Set both via Railway's GraphQL API (variableUpsert— Railway auto-seals values matching a secret pattern, e.g.sk_/pk_prefixes, which is why they read back asnulleven after being set correctly; this is expected/working-as-intended, not a bug). Verified end-to-end against the live API:POST /api/payment/initializenow returns a real Paystackauthorization_url, andGET /api/payment/verify/{reference}correctly reports an incomplete checkout as unsuccessful. - Dashboard/marketplace demo data (added): production DB had zero services/orders/transactions. Added
backend/database/seeders/DemoDataSeeder.php(15 services across all categories, 2 providers, wallet funding/spend history, and 4-5 orders per demo account with mixed statuses), wired intoDatabaseSeederand run once against production. Verified live:/api/servicesreturns 15 items,/api/walletshows a non-zero balance,/api/ordersreturns seeded orders, and/api/admin/dashboardreports realistic aggregate stats. - Edit Profile:
frontend/app/(dashboard)/profile/page.tsxnow supports inline editing (name/email) via a newuseUpdateProfileMutationhook (PUT /profile), syncing the result back into both TanStack Query's cache and the Zustand auth store. - Topbar search: a search input in
AppLayout's topbar (backed byuiStore.serviceSearchTerm) live-filters the Services page by name/category/description and auto-navigates to/serviceswhen a search starts elsewhere; clears automatically on navigating away. - Empty states with CTAs:
EmptyBlocknow accepts an optional icon + action. Wallet (no transactions → "Browse services"), Orders (no orders → "Browse services"), and Services (no search results → "Clear search") render a friendly icon, copy, and CTA button instead of a bare message. - Conditional admin sidebar: confirmed already correctly gated on
user?.is_admininAppLayout.tsxfrom a prior pass — no changes needed.
- Paystack payment flow (fixed):
- The
railwayCLI binary rejects certain valid account API tokens outright (Unauthorized/Invalid RAILWAY_TOKENonwhoami/status/list/variables/link), even though the same token authenticates fine directly against Railway's GraphQL API (https://backboard.railway.com/graphql/v2,Authorization: Bearer <token>). Root cause not identified; this is a known, still-open upstream bug (seerailwayapp/cliissue #699) affecting many users, not something specific to this project. Workaround is to bypass the CLI entirely and call the GraphQL API directly withcurl. - Token type matters and is easy to get wrong. Railway has three token types: account (
Authorization: Bearer, all resources), workspace (Authorization: Bearer, one workspace), project (Project-Access-Tokenheader, one environment). An invalid/mistyped token and a token-under-the-wrong-header-type can return identically-worded GraphQL errors (e.g."Not Authorized","Project Token not found"), so don't trust error text alone — always differential-test against a deliberately garbage token string first; if both give the same error, the token itself (not the header) is the problem. A real account token was confirmed working this way:query { me { id name email } }withAuthorization: Bearer <token>returned real user data instead of an error. - Once authenticated, discover project/service/environment IDs via
query { me { workspaces { id name projects { edges { node { id name } } } } } }, thenquery { project(id: "...") { services { edges { node { id name } } } environments { edges { node { id name } } } } }. - The GitHub source link can silently go stale, causing
serviceInstanceDeploy(..., latestCommit: true)to keep redeploying an old commit even though GitHub has newer ones. Symptom:deployments(...)query shows the new deploy'smeta.commitHashmatching the old commit. Fix: re-runmutation { serviceConnect(id: "<serviceId>", input: { repo: "owner/repo", branch: "main" }) }to refresh the link, then retryserviceInstanceDeploy. This is not a one-time fix — it recurred in this project and may need to be repeated in future sessions. - Set env vars via
mutation { variableCollectionUpsert(input: { projectId, environmentId, serviceId, variables: { KEY: "value" }, skipDeploys: true }) }(skipDeploys: trueavoids triggering a redundant deploy when you're about to trigger one anyway). - Reading a variable back via the
variablesquery can shownullfor values Railway has auto-sealed as secrets (anything matching common secret patterns, e.g.sk_/pk_prefixes) — this does not mean the write failed. Confirm viaenvironment(id) { variables { edges { node { name isSealed } } } }, which lists the variable withisSealed: true, or simply verify end-to-end via the application (e.g. hitting the API endpoint that consumes the variable). - For one-off production data work (seeding, inspection), the Postgres service's
DATABASE_PUBLIC_URL/individualPG*vars (visible via thevariablesquery against the Postgres service ID) give a publictokaido.proxy.rlwy.netendpoint reachable directly withpsqlor a local Laravel.env— no CLI tunnel needed. Always seed via the actual Laravel seeder (php artisan db:seed) rather than hand-written SQL, and never commit the temporary.envused for this. - Current resource IDs (Billy Chapel's account,
charpsdevproject = "generous-forgiveness"): projectId7c46f605-b32b-4229-9f65-bd33e96c4be4, serviceId689bac8f-8ae2-4339-b5fa-721838e7dbfd, environmentId (production)c3b33016-8ff8-4577-b369-a58f3dc65809.
- Railway backend redeployed to commit
e2cb709(Vercel Analytics, PWA manifest, push notification infrastructure) via the GraphQL API after the GitHub source link had gone stale again (see note above; fixed viaserviceConnectreconnect +serviceInstanceDeploy). - VAPID env vars set on Railway production (
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT) viavariableCollectionUpsert. push_subscriptionsmigration confirmed applied on the production Postgres DB (ran automatically via the existingpreDeployCommand: "php artisan migrate --force").- Live end-to-end verification against production (
https://charpsdev-production.up.railway.app):GET /api/push/public-keyreturns the correct VAPID key,POST /api/push/subscribe/POST /api/push/unsubscribeboth succeed, and updating an order's status viaPUT /api/admin/orders/{id}with a subscribed user attached completes with HTTP 200 (confirms theWebPushService::sendToUser()order-status-change trigger executes without error against real production data). Test subscription and status change were reverted/cleaned up afterward. - Frontend confirmed live at
https://charpsdev.vercel.app: manifest, service worker, and icons all return 200. - This closes out the full "Final Production Features Checklist": Skeleton loading, Error boundaries, Dark mode, Charts, Data tables, Analytics, PWA, Push notifications — all 8 items implemented, built, deployed, and verified live in production.
- Symptom: immediately after the push-notification deploy above, the user reported the live Admin/Orders "Create order" form returning a generic "Server Error", with the orders list showing "No orders yet".
- Root cause (pre-existing, unrelated to the push-notification deploy): the
orderstable migration (2026_07_20_164350_create_orders_table.php) never defined aquantitycolumn, butOrder::$fillableandOrderController::store()have writtenquantitysince the very first commit. EveryPOST /api/orderswas throwingPDOException: SQLSTATE[42703] undefined column "quantity" of relation "orders", which Laravel's generic exception handler surfaced to the frontend as{"message":"Server Error"}(HTTP 500). Confirmed via Railway'sdeploymentLogsGraphQL query against the live deployment — this is the fastest way to get the real PHP stack trace instead of guessing from the frontend's generic error text. - Fix: added migration
2026_07_29_214856_add_quantity_to_orders_table.php(unsignedInteger('quantity')->default(1), guarded byhasColumncheck so it's safe to run even if a future migration accidentally re-adds it). No application code changes were needed —Order,OrderController, and the frontend were already correct; only the schema was missing the column. - Deploy: pushed to
origin/main(commit233fedc), then redeployed via the same Railway GraphQLserviceConnect(source link had gone stale again, as expected per the recurring note above) +serviceInstanceDeploy(latestCommit: true)pattern. Deployment8826ed51-...reachedSUCCESSon commit233fedc; the new migration ran automatically viapreDeployCommand. - Verified live:
POST /api/ordersnow returns201with a real order record (quantity included), andGET /api/orderslists it correctly. Test order was set tocancelledafterward as cleanup (no delete-order endpoint exists in the API). - Lesson for future sessions: when a "Server Error" is reported from the frontend, go straight to Railway
deploymentLogsfor the real exception rather than guessing fromAdminOrderControllerchanges in the most recent deploy — in this case the bug long predated today's push-notification work and simply hadn't been exercised by the "Quick Wins" testing pass earlier.
This is the first phase of a broader 10-phase roadmap to evolve CharpsDev into a full digital marketplace. Phase 1 was built additively on top of the existing schema (wallet, orders, Paystack, admin panel, push notifications are all untouched) and was fully built and tested against a local SQLite dev environment before being considered for production, specifically to avoid repeating the earlier "Server Error" migration incident (see section 7 above).
Categories. A real categories table (name, slug, icon, status, sort_order) replaces the old free-text services.category string as the primary way services are organized, while keeping the old string column for backward compatibility. Seeded categories: Facebook Accounts, Instagram Accounts, TikTok Accounts, Twitter/X Accounts, Email Accounts, Streaming Accounts, Gift Cards, Digital Products.
Services. services gained nullable category_id (FK → categories), stock (nullable = unlimited/instant-delivery, otherwise a hard inventory count enforced on cart-add and checkout), and currency. The legacy category string column is still written on create/update (derived from category_id when only that's supplied) so nothing that reads the old column breaks.
Shopping cart. A cart_items table (user_id, service_id, quantity) backs a full add/update/remove/clear flow with per-item stock validation and ownership checks, so users can add multiple services before paying.
Multi-item checkout. orders gained order_number, total, payment_method, plus nullable service_id/quantity (kept nullable rather than dropped, for backward compatibility with the pre-existing single-item order flow). A new order_items table (order_id, service_id, quantity, price) captures each line item. Checkout is fully transactional: it row-locks (lockForUpdate()) the user's wallet and every service being purchased, debits the wallet, decrements stock, creates one Order + N OrderItems, writes both a wallet_transactions row (type=debit) and a transactions row (type=purchase), clears the cart, and fires a notification — all inside one DB transaction so a failure anywhere rolls back everything.
The old single-item "Create order" flow (POST /api/orders) still works unchanged — it was deliberately left in place alongside the new cart/checkout flow rather than removed, so both currently coexist in the UI (Orders page still has the old create form; Services page now also has "Add to cart" buttons). Reconciling/simplifying this into one flow is a candidate follow-up, not yet done.
| Method | Endpoint | Notes |
|---|---|---|
| GET | /api/categories |
Public list of active categories |
| GET | /api/admin/categories |
Admin list (all categories, with service counts) |
| POST | /api/admin/categories |
Create a category |
| PUT | /api/admin/categories/{id} |
Update a category (e.g. toggle status to hide/show) |
| DELETE | /api/admin/categories/{id} |
Delete a category |
| GET | /api/services?category_id= |
Filter services by category id |
| GET | /api/services?category= |
Filter by category slug or legacy category string |
| GET | /api/cart |
List the current user's cart items (total = sum of subtotals) |
| POST | /api/cart |
Add a service to the cart (service_id, quantity), stock-checked |
| PUT | /api/cart/{id} |
Update a cart item's quantity |
| DELETE | /api/cart/{id} |
Remove one cart item |
| DELETE | /api/cart |
Clear the entire cart |
| POST | /api/checkout |
Transactional checkout of the whole cart against the wallet balance |
| GET/POST/PUT/DELETE | /api/admin/services |
Now accepts/returns category_id, currency, stock |
Service has a categoryGroup() BelongsTo(Category::class, 'category_id') relation — deliberately not named category(), because that name collides with the pre-existing legacy category string column. Eloquent lets an eager-loaded relation silently overwrite a same-named raw attribute during toArray()/JSON serialization, which would make category unpredictably either a string or an object depending on what was eager-loaded on a given request. Over the wire, Eloquent automatically snake-cases relation keys, so this relation appears in every JSON response as category_group (not categoryGroup) — consistent with the snake_case convention used by every other field in this API (category_id, provider_id, created_at, ...). The frontend Service type reflects this: category_group?: Category | null alongside the always-a-string legacy category?: string.
Phase 1 was built and verified against a local SQLite database, not the production Postgres DB:
# backend/.env (local only — gitignored, never committed)
APP_ENV=local
APP_DEBUG=true
DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/backend/database/database.sqlitecd backend
touch database/database.sqlite # if it doesn't exist yet
php artisan migrate:fresh --seed --force
php artisan serve --host=127.0.0.1 --port=8787Both .env and database/database.sqlite are gitignored (confirmed via git check-ignore -v) and were never committed.
Full curl-based end-to-end pass against the local server: categories list, services list with both category_id and category-slug filters, cart add/get/update with stock-limit rejection, checkout (wallet debit verified exact amount, stock decrement verified, cart cleared, wallet_transactions.type=debit + transactions.type=purchase correctly split, notification fired), insufficient-balance checkout correctly rejected (cart/wallet/stock left untouched), the legacy single-item POST /api/orders endpoint confirmed still working, admin categories full CRUD (create/list/update/hide/delete), admin services list with category_group eager-loaded. Frontend npm run build (Next.js 16 + Turbopack) compiles cleanly with zero TypeScript errors across all 25 routes, including the two new pages (/cart, /admin/categories).
Phase 1 has been deployed to production — both backend (Railway) and frontend (Vercel):
- Backend: deployed to Railway via the GraphQL
serviceConnect+serviceInstanceDeploy(latestCommit: true)pattern (source link had gone stale again, as expected per the recurring note in section 7). Final deployment554d2fab-...reachedSUCCESSon commita4c0063, live athttps://charpsdev-production.up.railway.app. - Frontend: deployed via
vercel deploy --prod --token ..., run from the repository root (not from insidefrontend/) since the linked Vercel project's "Root Directory" setting isfrontend— running the CLI from insidefrontend/itself caused Vercel to append that setting onto an already-frontend-relative cwd, producing an invalid doubledfrontend/frontendpath error. Build compiled all 25 routes (including the new/cartand/admin/categories) with zero TypeScript errors and aliased successfully tohttps://charpsdev.vercel.app.
Production-only bug found and fixed during this deploy: services.category was originally defined via Laravel's enum() migration helper (['vtu','giftcard','esim','verification','digital','utility']). On Postgres this compiles to a hard CHECK constraint (services_category_check); SQLite doesn't enforce it at all, so it was invisible throughout local testing. Phase 1's catalog introduces two new legacy category values ('social', 'email') that violate this constraint, which only surfaced as SQLSTATE[23514] when seeding against real production Postgres. Fixed with migration 2026_07_30_003700_widen_services_category_check_constraint.php, which drops the constraint on Postgres only (no-op on SQLite/MySQL, confirmed via php artisan migrate --force locally before deploying). After the fix deployed, DemoDataSeeder was re-run against production Postgres successfully (categories 0→8, services 15→21, services with category_id 0→11).
Live production verification performed: logged in as test@example.com against https://charpsdev-production.up.railway.app/api, confirmed GET /api/categories returns all 8 seeded categories, GET /api/services returns category_group correctly eager-loaded (confirming the JSON key fix is live), added a service to the cart, ran POST /api/checkout, and confirmed: order + order_item created, stock decremented (59→57), cart cleared, wallet debited the exact order amount (₦70,800.00 → ₦68,400.00 for a ₦2,400.00 order). Also confirmed the deployed frontend serves /login, /dashboard, /cart, and /admin/categories with correct HTTP status codes (200 for public/authenticated pages, 307 redirect to /login?next=... for admin pages when unauthenticated) and no browser console errors.
Phases 2–10 of the roadmap (wallet refinements, additional payment gateways, Providers/Coupons/Settings admin pages, product delivery emails, provider API sync, more notification triggers, analytics, user-facing features, and additional security hardening) are intentionally not started, per the user's explicit sequencing.
Second phase of the 10-phase roadmap. Built additively on top of Phase 1's checkout/wallet code, tested locally against SQLite first (same discipline as Phase 1), and not yet deployed to production as of this writing.
Two separate ledger tables existed for wallet activity: transactions (read by the user-facing GET /api/wallet/transactions on the Wallet page) and wallet_transactions (a stricter credit/debit ledger, also used for balance bookkeeping). Historically only CheckoutController wrote to both tables on a purchase. PaymentController::creditWallet() (Paystack deposits) wrote only to transactions, and AdminWalletController::credit()/debit() wrote only to wallet_transactions — meaning any admin-initiated balance adjustment was completely invisible on the affected user's own Wallet page, even though their balance had actually changed. This was a real, pre-existing bug discovered while scoping Phase 2, not a new regression.
Unified ledger. AdminWalletController::credit()/debit() now write to both wallet_transactions and transactions inside the same DB transaction, row-locking the wallet (lockForUpdate()) exactly like CheckoutController already did. PaymentController::creditWallet() (Paystack) was fixed the same way, so all three money-movement paths — purchase, deposit, admin adjustment — now keep both ledgers in sync.
Transaction descriptions. transactions gained a nullable description column (migration 2026_07_30_010000_add_description_to_transactions_table.php, guarded with hasColumn, additive/non-destructive). Populated as: "Purchase: order {reference} (N item(s))" for checkout, "Wallet funded via Paystack" for deposits, and the admin-supplied reason (or a sensible default) for admin credit/debit. Shown as a new "Description" column on the user Wallet page.
Admin reason + drill-down. Admin credit/debit now accepts an optional reason (validated, max 255 chars) surfaced as a "Reason (optional)" input on the Admin Wallets page. A new "History" toggle per row expands an inline panel (no Dialog/Modal component exists in this codebase yet, so this follows the existing inline-<Card> convention rather than introducing one) showing that user's paginated transaction history via the new drill-down endpoint.
Deposit bounds. DepositRequest already enforced min:100; added max:5000000 so a single Paystack deposit can't exceed ₦5,000,000.
Insufficient-balance handling. AdminWalletController::debit() now wraps the balance check in ValidationException::withMessages(['amount' => 'Insufficient balance.']) inside the DB::transaction() closure, so an over-debit cleanly rolls back and surfaces a 422 instead of partially applying.
| Method | Endpoint | Notes |
|---|---|---|
| GET | /api/admin/wallets/{user}/transactions |
New — paginated (20/page) transaction history for one user, admin-only |
| POST | /api/admin/wallets/{user}/credit |
Now accepts optional reason; writes to both ledgers |
| POST | /api/admin/wallets/{user}/debit |
Now accepts optional reason; writes to both ledgers; rejects with 422 on insufficient balance (no partial effect) |
AdminWalletController::debit() initially called $user->wallet()->fresh()->balance to report the post-debit balance, which throws BadMethodCallException because fresh() doesn't exist on a HasOne relation object (only on a loaded model instance). Confirmed via curl that the underlying DB transaction had actually succeeded (balance correctly changed in the database) despite the request returning a 500 — the bug was purely in the response-formatting line. Fixed to $user->wallet()->first()->balance and re-verified.
Admin credit/debit with and without a reason; insufficient-balance debit correctly rejected with balance left untouched; reason field's 255-char max enforced; deposit min:100/max:5000000 bounds enforced; Paystack deposit path exercised via a php artisan tinker reflection call (no live Paystack keys in local dev) and confirmed it now writes both transactions and wallet_transactions; Phase 1's full checkout flow re-verified end-to-end still working, now with the description field populated on the resulting transaction; the new /api/admin/wallets/{user}/transactions endpoint confirmed admin-only (a non-admin user gets 403). Frontend npm run build (Next.js 16 + Turbopack) compiles cleanly with zero TypeScript errors across all 25 routes.
The originally-approved Phase 2 scope also included linking a purchase-type transaction on the user Wallet page to its underlying order. Not implemented in the original Phase 2 pass; implemented in §11 below.
Committed locally as 9527c2d (backend) and 1e7ddeb (frontend), pushed to origin/main. Now deployed to production on both Railway and Vercel (see §11 for the deploy that shipped this, plus the order-link feature, together).
Phases 3–10 of the roadmap remain intentionally not started.
Frontend-only UX redesign, requested independently of the phase roadmap above. No backend/API changes — all data (services, categories, prices, stock, category icons) was already available via existing endpoints.
Featured Services banner. A horizontally-scrollable row of up to 6 active services appears at the top of the Services page, shown only when no search term or category filter is active.
Category chips. A horizontally-scrollable pill row ("All" + every active category, each with its icon and a service-count badge) sits directly below the search bar.
Redesigned service cards. Smaller cards in a responsive 2/3/4-column grid, each showing a brand/category icon bubble, the service name, an out-of-stock/low-stock badge where relevant, a price tag, and a circular chevron affordance in the corner to signal the whole card is tappable (tapping adds the service to the cart, same behavior as before — only the visual design changed).
"Starting from ₦..." pricing label. Services whose legacy category is vtu, utility, or esim (base/minimum-price, top-up-style products where the actual amount is chosen at purchase time) now show "Starting from ₦X" instead of a flat price. Implemented via hasVariablePricing() in frontend/lib/serviceIcons.tsx.
Icon resolution. New frontend/lib/serviceIcons.tsx resolves an icon for each service/category through a fallback chain: (1) brand-keyword match against the service name (e.g. "Netflix Premium" → actual Netflix glyph), (2) the linked category's seeded icon string, (3) the legacy category enum string, (4) a generic package icon. Brand glyphs (Facebook, Instagram, TikTok, WhatsApp, Telegram, Netflix, Spotify, etc.) come from react-icons/si (Simple Icons) — added as a new dependency (react-icons@^5.7.0) because lucide-react has no brand/logo icons.
Mobile bottom navigation. New frontend/components/layout/BottomNav.tsx: a fixed, mobile-only (md:hidden) bottom tab bar with exactly 5 items — Home, Services, Orders, Wallet, Profile — with iOS safe-area padding. Wired into AppLayout.tsx; the page content area gained pb-24 on mobile so it isn't obscured by the bar.
| File | Change |
|---|---|
frontend/lib/serviceIcons.tsx |
New — icon-resolution + variable-pricing helpers |
frontend/components/layout/BottomNav.tsx |
New — mobile bottom tab bar |
frontend/components/layout/AppLayout.tsx |
Renders BottomNav; adjusted content padding |
frontend/app/(dashboard)/services/page.tsx |
Rewritten — banner, chips, redesigned cards |
frontend/package.json / package-lock.json |
Added react-icons dependency |
npm run build succeeds with zero TypeScript errors across all routes including /services. Exercised the full auth pipeline locally (backend on :8787, frontend on :3000) via a temporary QA harness (deleted before commit) to confirm /services renders 200 past both the Next.js middleware and the client-side ProtectedRoute guard, and that the rendered page contains the redesigned markup with no client-side console/hydration errors beyond the expected dev-only HMR WebSocket noise.
Committed as 95b4a15, pushed to origin/main. Deployed to production on both Railway and Vercel — see §11.
Closed the Phase 2 "known gap" (§9) and pushed both Phase 2 and the Services redesign (§10) to production.
- Backend:
Transaction::order()—belongsTo(Order::class, 'reference', 'reference'). Purchase-type transactions share the same generatedORD-...reference as the orderCheckoutControllercreates in the same DB transaction; deposit/admin-credit/admin-debit transactions use distinct reference formats (UUID /CRD-.../DBT-...), so this naturally resolves tonullfor them — no migration/new column needed.WalletController::transactions()eager-loads a minimalorder:id,reference,order_number,statusand explicitly nulls it for any non-purchasetransaction as a defensive guard. - Frontend:
Transaction.orderadded totypes/api.ts; the Wallet page shows a "View order →" link under a purchase transaction's description, linking to/orders?ref=<reference>; the Orders page reads thatrefparam and scrolls to + highlights the matching row. - Committed as
bdc1a6b, pushed toorigin/main.
- GitHub source link had gone stale again (recurring issue, see §7 notes) — reconnected via
serviceConnect, thenserviceInstanceDeploy(latestCommit: true)against commitbdc1a6b. Deployment reachedSUCCESS. - Verified live at
https://charpsdev-production.up.railway.app:/upreturns 200; login works;GET /api/wallet/transactionsfor a real production user shows a genuine purchase transaction (ORD-DCKX0GFOLD) correctly resolvingorder: {id: 13, ...}, while older demo-seeded purchase transactions (different reference format) and deposit transactions correctly resolveorder: null; confirmed order13exists viaGET /api/orders; deposit-cap enforcement (amount > 5000000) correctly rejected with 422; admin drill-down endpoint (/api/admin/wallets/{user}/transactions) confirmed working with a real admin login. Production now runs commitbdc1a6b(Phase 2 + Services redesign + order-link, all in one deploy since they'd accumulated onmain).
The originally-available VERCEL_TOKEN (a personal-account token) authenticated basic user identity (GET /v2/user) but every team/project-scoped call (vercel deploy, vercel inspect, GET /v9/projects/..., GET /v6/deployments) was rejected with 403 forbidden, "saml": true, "scope": "charps-dev" — the charps-dev Vercel team enforces SSO and this token type could not satisfy it programmatically. This was a hard account/token-permission blocker, not a code issue.
Unblocked with a new, team/project-scoped token supplied by the user. This token type behaves differently from a personal token: GET /v2/user and GET /v2/teams both fail (expected/harmless for this token type), but it authenticates successfully directly against the project endpoint (GET /v9/projects/{id}) and against vercel deploy itself.
Deploy gotcha found and fixed: running vercel deploy --prod --token ... --yes from inside frontend/ failed with Error: The provided path ".../frontend/frontend" does not exist — the CLI double-applies the project's configured rootDirectory: "frontend" when invoked from a directory that's already linked to that root via its own .vercel/project.json. Fix: run the same command from the monorepo root instead, letting the CLI apply rootDirectory correctly on top of the repo root.
Deployed and verified:
vercel deploy --prodperformed a cloud-side build (Vercel's own infra runs the Next.js build remotely —Build Completed in /vercel/output [28s], all 22 routes generated matching the local build), producinghttps://charpsdev-3m5awc6i4-charps-dev.vercel.app, aliased to productionhttps://charpsdev.vercel.app("✓ Ready in 60s").- Freshness confirmed via
age: 0response header on/loginimmediately after deploy (vs.age: 136315/ ~38h observed before this deploy, proving the site had genuinely been stale). NEXT_PUBLIC_API_URLconfirmed correctly set tohttps://charpsdev-production.up.railway.app/apifor production/preview/development via the Vercel env-vars API.- Full authenticated end-to-end check: logged into the production Railway backend (
test@example.com/password), then hithttps://charpsdev.vercel.app/services,/wallet, and/orders?ref=ORD-DCKX0GFOLDwith the resulting session cookie — all three returned HTTP 200, confirming the new Services redesign, wallet, and transaction→order-link UI are all live and working against the real production backend.
Known discrepancy, not yet acted on: GET /v9/projects/{id} reports the project's linked GitHub org as "Clearestkess", while all git push/pull operations in this project consistently resolve to "Clearkess" (https://github.com/Clearkess/charpsdev). This mismatch is a plausible reason the Vercel Git integration has never auto-deployed on push to main (the site remained stale despite multiple prior pushes) — this deploy was completed via direct CLI invocation, which bypasses git integration entirely. Worth fixing the project's Git integration link (or generating a persistent, correctly-scoped token) so future pushes to main auto-deploy without a manual vercel deploy.
Post-deploy checks from §3 (login, dashboard, wallet, orders, admin) have been re-run against https://charpsdev.vercel.app and pass.