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’ssubscription_status enum has six states (src/database/schema/enums.ts):
Subscribing: create then verify
The subscribe flow is inSubscriptionsService.subscribe() (src/subscriptions/subscriptions.service.ts).
- Free publications (
monthlyPriceKobo === 0) skip Duro entirely and are granted locally, with thecheckoutUrlpointing 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/atRiskMrrKobocome from subscriptions inpast_due(set by Duro’ssubscription_past_duewebhook). This is “money currently in dunning.”recoveredCount/recoveredRevenueKoboare a filtered aggregate overledger_entriestaggedSubscription recovery, written when asubscription_payment_recoveredwebhook lands. This is “money Duro brought back.”recoveryRate=recovered / (recovered + atRisk), rounded to one decimal.
Content gating keys off the mirrored status
The mirroredstatus 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.