Skip to main content
Complete work archive

Production / Education platform

MyChatLab

A gamified English-learning product for children, sold to parents by subscription and to schools through teacher accounts.

Relationship
Client assignment · SoftwareSeni / Fun Gum
Role
Full-stack Engineer
Period
Aug 2021 - Feb 2022
Website
https://lexolab.com (opens in a new tab) (View archived website)

At a glance

  • Pinned a two-to-four-minute lesson operation on cross-region database latency, paid per query
  • Found it by comparison: the same script run from Indonesia, then from Australia
  • Reconciled subscription state written by Stripe, Midtrans, and WooCommerce into one record
  • Traced two screens disagreeing on the same metric to definition drift, not a broken query
  • Junior engineer on an established team; architecture and hosting decisions were not mine

Stack: PHP, Laravel, MySQL, React, Stripe, Midtrans, Sentry, jQuery, Bootstrap

Inputs

Stripe
Midtrans
WooCommerce
Learning activity
Education platform

Outputs

Subscription state
Localized email
Admin reporting
What this system takes in, and what reads from it.

What I built

Owned subscription and payment workflows across three providers, along with error monitoring and supporting work on the React student app.

System context

MyChatLab, built for Fun Gum in Auckland, is a gamified English-learning product for children. It was renamed twice while I worked on it, so it also appears as Chit Chat and Lexo Lab. It sells two ways: to parents by subscription, and to schools through teacher accounts. Children learn under a parent or a teacher, and do not hold their own billing relationship.

Subscription state therefore drives most of the product. It decides what a child can open, what a teacher can see across a class, when a trial ends, and which currency and language a person is billed and emailed in. Three payment providers write into it: Stripe for cards and subscriptions, Midtrans for Indonesian payments, and WooCommerce for the storefront.

The other constraint was where the two halves ran. The backend ran on an Indonesian server, and the MySQL database was an RDS instance in Australia.

SoftwareSeni assigned me to MyChatLab from August 2021 to February 2022. My role was Full-stack Engineer. The backend and CMS used Laravel and MySQL 8, and I worked on the React student app when a change crossed the API and interface together.

My scope

I owned

  • The subscription lifecycle across Stripe, Midtrans and WooCommerce, including trials, expiry, and cancellation.
  • The localized transactional email system.
  • Production error monitoring and the investigations that came out of it.
  • Diagnosing conflicting metric definitions between the reports and the app.

I contributed to

  • The database indexing and foreign-key pass, and query optimization in the subscription loading paths.
  • The Asana content import, and the WooCommerce and Midtrans payment endpoint.

Team-owned

An established product with an existing team, and I was the junior engineer on it. Tickets came from a project manager, everything went through merge review, and I raised the issues I found for the team to pick up.

Key engineering work

The same script, two servers, four minutes apart

Production debugging

Problem

Skipping a lesson was taking two to four minutes in production. From the application code there was nothing obviously wrong, and the operation was not doing an unreasonable amount of work. Reports of slow page loads had been accumulating for a while without a cause.

Action

I stopped reading the code and ran the same script on two servers. From the Indonesian backend it took two to four minutes. From an Australian server it took under a minute. The application path was the same in both runs, so the comparison pointed to the network path to the RDS instance in Australia, with the latency cost paid per query, not once per request.

Result

A vague performance complaint became a specific, located cause: cross-region latency between the application server and the database, amplified by query count. That moved the fix from optimizing the lesson-skip code to reducing round trips and reconsidering where the two halves run. I reported the two-server comparison on the project channel so the hosting decision remained with the people who could make it.

One subscription state, three providers writing to it

Payments and state modeling

Problem

Three providers meant three webhook shapes and three notions of what a completed payment is, all landing in one user_subscriptions record. Status had grown into a set of strings covering trial, monthly, and one-off purchases per provider. A new signup was writing plain "Paid" where it should have written the more specific monthly status, so downstream checks read the wrong thing.

Action

I worked the lifecycle end to end: separate statuses for one-off Stripe and one-off Midtrans payments, an aggregate filter for every paying user, trial and expired filters for the admin list, a computed remaining-days field that goes negative once expiry has passed, coupon codes recorded against the payment, and cancellation from the parent record. Children inherit subscription state from whichever parent or teacher owns them, so a class does not need per-child billing.

Result

Administrators could filter paying, trial, and expired accounts from one subscription model, while children inherited access from the parent or teacher who owned the billing relationship. To confirm the signup fix I ran a real checkout through to completion, then read the user_subscriptions row it produced. The defect was in what got written, so the status the interface displayed would not have told me anything. When duplicated email data broke payment lookups after an address change, I traced the drift to the subscriptions table and made profile updates propagate to it. The substring-based cancellability check remained a compromise in the existing status model.

Two screens counting the same thing differently

Data correctness

Problem

The reports and the in-app statistics disagreed consistently, and not by a rounding error. Neither was throwing an error, and both were reading the same database.

Action

I traced both queries for each disputed number. Login count: the reports counted login records and session records; the stats page counted only login records. Word vault count: the reports counted two categories of completed task; the app counted one. Completed and remaining lessons disagreed for the same reason. Neither query had a defect; each one counted a different set of records under the same label.

Result

I documented the definition behind each metric and reported the finding in the project channel. Comparing the underlying record categories showed that the defect was definition drift and not a broken query. The assignment ended before anyone reconciled those queries, so what came out of this was a diagnosis on record and not a shipped fix.

Email that has to arrive in the right language and currency

Localization

Problem

The product sells across languages, and a transactional email is where a subscription becomes visible to the person paying for it. Translating the copy was straightforward; the header images, the placeholder values and the currency shown alongside them were what needed handling.

Action

I built the email content on translatable text with placeholders, including trial expiry, so a message could carry account-specific values in any supported language. Header images switch per user language, and a trial user's subscription currency follows their selected language, not a platform default.

Result

Account language became the shared input for localized email content and trial-subscription currency, instead of choosing those independently per message.

Other contributions

  • Indexing pass

    Added indexes and foreign keys across existing tables, measured the effect on query load rather than assuming it helped, listed every index in a shared document, and dropped some again on production where they did not earn their write cost.

  • A half-run migration

    Migrations were not running on production, and were not failing with an error either. Server logs showed one had run partway and stopped on a column that did not exist there, leaving the schema between two states. The remaining files were re-run with DevOps instead of blindly.

  • Export path

    Stats export returning a 500, exports hanging on a loading screen, and a type error in the table export plugin. Implemented export on the users table with selectable columns.

  • Content pipeline

    Lessons and books were authored in Asana and imported. I ran the imports into dev and staging, and traced cases where production lessons had not imported their books.

  • Scoring

    Added the points table and wrote the MySQL queries computing user and platform-wide totals, plus deriving a pretest score from breakdown data already stored as JSON.

  • Family accounts

    Made the parent username nullable across signup and trial signup, and fell back to the parent email when a child has no username.

Technical decisions

Computing scores by querying inside JSON columns was a compromise. It avoided a migration and a backfill on data already stored that way, at the cost of index usage on every read. It met the deadline and it is not what I would design from scratch.

The slow queries could have been addressed by rewriting them or by adding indexes. I proposed the rewrite and measured an improvement from it, raising the comparison on the ticket; the team preferred indexes, so that is what I implemented. Each index was then checked against query load, and the ones whose write cost was not earning anything back were dropped from production.

Engineering takeaway

Both causes here were found by comparison rather than by reading code. Running the same script from Indonesia and from Australia pointed to the network path to the RDS instance, and reading the report and stats queries side by side showed they were counting different record types under the same label. Neither cause was visible in the code I would have started with, so when something is slow or two numbers disagree I now measure the same thing twice, in two places, before changing anything.

The two-to-four-minute figure is my own measurement from running the same script on two servers, not a published benchmark.