Skip to main content
Quill does not run a billing engine. It holds a subscriptions row that mirrors the state of a Duro subscription, and it reacts to Duro’s events. The value of building on Duro shows up most clearly here: recovery-first billing is Duro’s product, so Quill gets at-risk tracking and recovery metrics by reflecting webhooks, not by implementing dunning.

The status model

Quill’s subscription_status enum has six states (src/database/schema/enums.ts):
Transitions come from two directions: reader/creator actions (which call Duro and then update the row) and Duro webhooks (which update the row to match Duro). Duro remains authoritative; Quill’s row is a cache for rendering and content gating.

Subscribing: create then verify

The subscribe flow is in SubscriptionsService.subscribe() (src/subscriptions/subscriptions.service.ts).
  • Free publications (monthlyPriceKobo === 0) skip Duro entirely and are granted locally, with the checkoutUrl pointing straight at the reader view.
  • Paid publications return a Duro checkoutUrl. The reader completes payment on Duro’s hosted checkout, then is redirected back to Quill’s /checkout/{reference} page.

Dual-path activation

Activation can arrive two ways, and Quill handles both idempotently: verify polls Duro’s checkout status: if it is completed and carries a subscriptionId, Quill fetches the subscription for the real period dates and calls processActivation. Independently, the subscription_activated webhook calls grantActive. Whichever wins, processActivation guards the ledger so the charge is recorded exactly once (see below). This is the same “the page may return before the webhook” reality Duro’s own checkout handles, mirrored on the merchant side.

Exactly-once revenue recording

processActivation writes an append-only ledger_entries row, but only after a dedup check. The charge reference is deterministic (dinv_{invoiceId} when Duro supplies an invoice, else {subId}:{periodEnd}), and an existing row with that reference short-circuits: This is what lets the synchronous verify and the asynchronous webhook both call activation safely: the second one to run finds the ledger row already present and stops.

Reader and creator controls

SubscriptionsController exposes the lifecycle actions (src/subscriptions/subscriptions.controller.ts). Each mutating action calls the matching Duro method (when a duroSubscriptionId exists) and then updates the local row: Recover is the reader-initiated counterpart to Duro’s automated dunning: for a past_due or canceled subscription, it opens a fresh Duro checkout so the reader can re-pay. (A paused subscription is redirected to resume instead of recover.)

The recovery-first creator view

This is where building on Duro pays off. recoveryStatsForOwner (guarded @Roles('creator')) computes a recovery summary directly from Quill’s mirrored state and ledger, populated by Duro’s webhooks: The metrics (RecoveryStats):
  • atRiskCount / atRiskMrrKobo come from subscriptions in past_due (set by Duro’s subscription_past_due webhook). This is “money currently in dunning.”
  • recoveredCount / recoveredRevenueKobo are a filtered aggregate over ledger_entries tagged Subscription recovery, written when a subscription_payment_recovered webhook lands. This is “money Duro brought back.”
  • recoveryRate = recovered / (recovered + atRisk), rounded to one decimal.
Quill never decides when to retry, which rail to use, or how to sequence dunning emails. All of that is Duro’s dunning engine and renewal engine. Quill’s job is to reflect the outcome and put “what you almost lost, and got back” in front of the creator. That mirroring is the strongest evidence that Duro’s recovery-first design is consumable by a real product.

Content gating keys off the mirrored status

The mirrored status is load-bearing beyond analytics: it gates premium content. A premium post or video is unlocked only for a user with an active subscription to that publication. Video goes further and gates the HLS decryption key itself (VideosService.getKey refuses non-active subscribers). So a lapse Duro reports via subscription_past_due immediately, and correctly, revokes access, and a recovery restores it, with no extra code path in Quill. Next: Payouts & VAS, the money-out side of the same integration.