AdPlatformForge turns one product photo into a short video ad or a social post design, in Arabic, French or Hassaniya. It is a multi-tenant SaaS: Spring Boot on the back end, Angular on the front, PostgreSQL, FFmpeg for video, and an LLM for the script.
AI calls are slow, flaky and cost money. This post is about the four design choices that kept the system honest: the pipeline shape, the provider interfaces, the credit ledger and the safety checks.
The pipeline is a line of persisted steps
A generation is not one request. It is a production line, and each step writes its status and progress to the database before doing the work:
step(id, GenerationStatus.ANALYZING, 10); // read the photo and the brief
step(id, GenerationStatus.ENHANCING_IMAGE, 25); // clean up the product image
step(id, GenerationStatus.WRITING_SCRIPT, 40); // headline, lines, call to action
// moderation check here
step(id, GenerationStatus.SYNTHESIZING_VOICE, 55); // video modes that speak
step(id, GenerationStatus.RENDERING, 70); // design or video
// COMPLETED at 100
The method runs on a dedicated executor with @Async("studioExecutor"), and the tenant id is set on a thread-local at the start and cleared in a finally block, because the async thread does not inherit the request context.
Because every step is persisted, the front end just polls the job and shows real progress, and failures are never silent. There is one more detail I am glad I added. If the server restarts while jobs are running, they can never finish, so on startup they are failed explicitly:
@EventListener(ApplicationReadyEvent.class)
public void failInterruptedJobs() {
// QUEUED, ANALYZING, ENHANCING_IMAGE, WRITING_SCRIPT, SYNTHESIZING_VOICE, RENDERING
// -> FAILED, "The server restarted while this job was running. Please create it again."
}
A job stuck at "Rendering 70%" forever is the worst experience. A clear failure that refunds the customer is much better.
Every AI provider sits behind an interface
The pipeline never talks to a vendor directly. It depends on small interfaces: ScriptWriter, ImageEnhancer, VoiceSynthesizer, AvatarVideoGenerator, BrollVideoGenerator, KineticVideoRenderer, DesignComposer. Real implementations (an LLM for scripts, an image-cleanup service, a voice service, an avatar-video service, FFmpeg for kinetic video) sit next to mock implementations of the same interfaces.
This paid off three ways:
- Development without keys. The whole pipeline runs end to end in a mock mode, so I could build and test the UI and billing without spending anything.
- Swappable vendors. Changing a provider is a new class and a config change, not a pipeline rewrite.
-
A single error type. Providers throw a
ProviderExceptionwith a message that is safe to show. Anything else becomes a generic "Something went wrong" while the real error goes to the log.
Credits: a failed ad must cost nothing
The product is billed in credits. Money-like numbers deserve a ledger, not a counter, so every movement is a row: plan grants, top-ups, charges, refunds and manual adjustments.
public enum CreditEntryType {
PLAN_GRANT, PLAN_EXPIRE, TOPUP, GENERATION_CHARGE, REFUND, ADJUSTMENT
}
Two buckets exist per tenant. Plan credits are granted monthly and do not roll over. Top-up credits are purchased and never expire. Spending uses plan credits first. The tenant row is locked while a balance changes, so two parallel generations cannot both spend the same credit.
The refund is the part I care about most:
@Transactional
public void refund(String tenantId, Long generationId, String reason) {
String ref = "gen-refund-" + generationId;
if (ledger.existsByTenantIdAndReference(tenantId, ref)) return; // safe to call twice
// ...find the original charge, give back exactly what it took from each bucket
}
It is idempotent through a unique reference on the ledger, so retries and double calls cannot refund twice. It also handles an edge case: if the original charge used plan credits from a previous month, those credits would have expired, so they come back as top-up credits instead of vanishing.
The pipeline calls the refund from both failure paths, a provider failure and a blocked script:
/** A failed or blocked ad must never cost the customer anything. */
private void refund(String tenantId, Long id, String reason) { ... }
The affordability check also happens before anything is stored (assertAvailable), so a customer without enough credits fails fast, before any slow work starts.
Safety: a conservative guard plus a human
Generated ads can go wrong in ways that matter: politics, adult content, gambling, alcohol, unverifiable health promises. A ContentGuard checks the script before any rendering money is spent. It is keyword-based and covers three languages, and its own Javadoc is honest about the limits: it is a conservative safety net, not a replacement for the LLM's system-prompt rules or for manual approval.
The riskiest feature, using a licensed likeness of a public figure, never relies on a keyword list. It is gated by a valid license, an approval queue, a usage log of every request (approved or not) and a kill switch. A blocked request is marked REJECTED, logged and refunded.
What I would tell my past self
- Persist every pipeline step. Progress bars and failure handling come for free.
- Put each vendor behind an interface and build the mocks first.
- Treat credits as a ledger with idempotent operations and exact refunds.
- Run cheap checks (balance, moderation) before expensive work.
- Be honest in code comments about what a safety check does not cover.
Talk to me
I build SaaS products, async pipelines and billing systems end to end, and I take freelance full-stack and backend projects: mahmoud-farouk-portfolio.vercel.app.
How do you handle refunds and idempotency in your own billing code? I would like to compare notes.



Top comments (0)