Skip to content

Add Subscriptions - #6

Open
MikeAlhayek wants to merge 177 commits into
mainfrom
ma/subscribtions
Open

MikeAlhayek wants to merge 177 commits into
mainfrom
ma/subscribtions

Conversation

@MikeAlhayek

@MikeAlhayek MikeAlhayek commented Jul 29, 2024

Copy link
Copy Markdown
Member

Fixes #11

This pull request introduces several foundational changes to support a reusable e-commerce architecture, along with various project and dependency updates. The most significant addition is a comprehensive decision record documenting binding architectural choices for the e-commerce foundation, ensuring consistency and future-proofing before domain-specific modules are developed. Additionally, the solution and package configuration files have been updated to include new e-commerce-related modules and dependencies.

Solution and module structure updates:

  • Updated CrestApps.OrchardCore.slnx to include new abstraction, core, and module projects for e-commerce domains such as Addresses, Checkout, Payments, Receipts, Subscriptions, Taxation, Transactions, Products, Commerce, PayLater, Stripe, and others, ensuring these modules are part of the solution and available for development and testing. [1] [2] [3]

Dependency and build configuration:

  • Added new package dependencies in Directory.Packages.props, including Stripe.net, OrchardCore.ContentManagement.Display, OrchardCore.ReCaptcha.Core, OrchardCore.RateLimits.Core, OrchardCore.Setup.Abstractions, and OrchardCore.Users, to support new commerce and security features. [1] [2] [3] [4] [5]
  • Enhanced Directory.Build.props to allow inheriting parent props files, enabling easier override of build properties such as target frameworks in multi-repo or nested scenarios.

@github-actions

github-actions Bot commented Aug 6, 2024

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@MikeAlhayek
MikeAlhayek marked this pull request as ready for review August 7, 2024 22:29
MikeAlhayek and others added 6 commits August 18, 2026 01:26
Fixes three defects reported for the Transactions module and Pay Later:

- Register the 'Transaction' YesSql collection in StoreCollectionOptions so
  the Transaction_Document table is provisioned (no such table error).
- Move the reminder service, background task, reminder settings driver, and
  reminder settings menu into a new opt-in 'Transaction Reminders' feature
  (CrestApps.OrchardCore.Transactions.Notification) that depends on
  OrchardCore.Notifications, so INotificationService resolves only when the
  feature is enabled. AdminController now treats the reminder service as
  optional and hides the Send reminder action when the feature is off.
- Add a new CrestApps.OrchardCore.Commerce module that owns the shared
  Commerce top-level admin menu and its icon (enabled by dependency only).
  Transactions and Taxation now depend on it and contribute children under
  the shared node, so the menu icon renders consistently.

Documents the Commerce and Transactions modules and updates the changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…p 5.3

- Replace the chevron breadcrumb step header with a horizontal
  line-with-circles stepper that visualizes progress: completed steps are
  green (with a check), the current step is highlighted with a warning
  color, and upcoming steps are muted. Uses native Bootstrap 5.3 utility
  and contextual classes with minimal custom CSS (only circle sizing and
  the connector line).
- Rebuild the payment-step invoice as a standard Bootstrap table using
  contextual table colors (table-light/secondary/active) and right-aligned
  amounts, removing the grid-offset layout so it relies on native Bootstrap
  classes with no custom CSS.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Transactions:
- Introduce a registrable transaction source registry (TransactionSource,
  TransactionSourceOptions, AddTransactionSource) in Transactions.Core so
  modules register a friendly, localizable source name.
- Turn the admin report Source filter into a dropdown fed from registered
  sources, and show the source display name in the table.
- Remove the "Total outstanding" alert (and its full-scan query) from the
  admin report.

Pay Later:
- Register its "pay-later" source with a localized display name.

Receipts:
- Print only the receipt: the print stylesheet now suppresses the surrounding
  page chrome (site header and admin menu) instead of only the buttons.

Docs:
- Document the registrable source mechanism and the print behavior; update the
  3.0.0 changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…a tests

My Transactions:
- Give the customer statement the same toolbar as the admin report: a search
  bar, a status filter dropdown, and a pager. Removed the old All/Outstanding
  toggle and the outstanding-total alert for consistency.
- Extract the shared status-filter-to-query and toolbar-item logic into
  TransactionStatusFilterExtensions, used by both controllers.

Subscriptions checkout:
- Always show the chosen payment method on the payment step, even when only one
  method exists (previously submitted through a hidden field and never shown).

Tests:
- Disable test-collection parallelization. Json.Schema's global SchemaRegistry
  is not thread-safe, so building schemas from parallel collections
  intermittently corrupted it and failed CI. Serializing collections removes
  the race with negligible cost (test execution 44s -> 52s).

Docs:
- Update transactions.md and the 3.0.0 changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The "My Subscriptions" subscriber dashboard now pages both the Payments and
Subscriptions lists, each with its own independent pager so navigating one list
never disturbs the other. Payments are paged at the database level and remain
ordered by payment date (latest first); subscriptions are flattened from their
sessions, sorted by start date (latest first), and paged in memory. Each pager
uses a distinct query key (invoicesPage / subscriptionsPage) via the Pager
shape's PagerId, and the page size follows the site-wide pager option.

Also refresh the ecommerce planning doc to reflect the committed Transactions,
Receipts, and Commerce infrastructure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MikeAlhayek

Copy link
Copy Markdown
Member Author

@davidpuplava @wAsnk The Subscriptions module has been a long time coming! I apologize for the delay — other modules ended up taking priority and, as a result, this one was put on the back burner for a while.

You may have moved on, no longer need a Subscriptions module, or even built your own solution by now. I’m not sure what you’ve ended up doing, but I wanted to let you know that I’ve been actively working on this and have made quite a bit of progress. The module has evolved significantly from where we started, and it now provides a foundation that could support building a much broader e-commerce solution on top of it.

This PR currently includes the following modules:

  • Taxation — Provides a robust foundation for handling taxes related to subscriptions and product sales.
  • Product — Provides the foundation for selling products.
  • Checkout — Provides the foundation for handling the checkout and payment process.
  • Addresses — Provides address management for things like billing and shipping.
  • PayLater — Provides a checkout/payment provider for deferred payments and managing Pay Later transactions.
  • Receipts — Provides services for handling commerce receipts, including printing and other receipt-related functionality.
  • Stripe — Provides Stripe as a checkout/payment provider.
  • Subscriptions — This is, of course, what started the whole effort. It provides subscription-based products, allowing users to subscribe and manage their subscriptions, while also providing the necessary payment handling and admin/user management UI.
  • Transactions — Provides one place for transactions and transactions management. Transactions can come from the Subscriptions module and other future transactions.
  • Commerce — Provides a placeholder feature for anything related to the Commerce menu item since multiple modules contribute to this admin menu.

If you’ve moved on from this or no longer have a need for it, I completely understand. But if you’re still interested, I’d really appreciate it if you could take some time to test it out and share your feedback.

I’ve documented everything in the documentation site included with this PR, so there should be enough information to understand how the pieces fit together and to get the project running.

If you do have a chance to try it, please let me know if you run into any issues, have questions, or — even better — have suggestions for how it could be improved. I’m particularly interested in feedback on the overall architecture and whether the modules provide a good foundation for future development.

At this point, I think the structure is flexible enough that we could eventually build a full enterprise-level commerce solution on top of these modules. That’s probably a discussion for another day, but it’s one of the directions this work could support.

Either way, I’d really appreciate your feedback. It would be helpful to know whether this is still something you’re interested in so I can prioritize my time accordingly.

@davidpuplava

Copy link
Copy Markdown

Hi @MikeAlhayek - I'm still interested in the subscription feature and I'll take a look! Thanks for picking it back up and I'll provide feedback when I get a chance to test.

MikeAlhayek and others added 2 commits August 18, 2026 07:42
Refactor existing modules and add reusable contracts so a future
e-commerce domain (Customers/Orders/Carts/Storefront) can be built
without introducing further breaking changes.

- G1 Customers: add Customers.Abstractions/Core with a customer and
  guest-ownership seam and boundary tests.
- G2 Currency ownership: a product now owns its currency. The single
  fallback lives in DefaultProductSnapshotResolver; pricing, taxation,
  Stripe price sync and subscription billing all derive currency via
  IProductSnapshotResolver, fail closed on a blank product currency and
  never apply implicit FX. TaxService validates item/context currency
  before dispatching to any determination provider.
- G3 Transactions: settlement and concurrency hardening.
- G4 Financial documents: interface-only Commerce.Abstractions seam
  (IFinancialDocumentPolicy / IFinancialDocumentNumberGenerator) with a
  receipts-only default policy and no speculative number generator.
- G5 Commerce boundary: Commerce stays a thin orchestration shell;
  CommerceModuleBoundaryTests fail the build if it takes on persistence,
  indexes or migrations, or if Commerce.Abstractions references domain
  persistence or providers.

Docs updated (products, subscriptions, commerce, transactions,
changelog). Full suite: 2246 tests pass; docs site builds clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Simplify Stripe account connection to a single streamlined flow: paste the
secret key and click Connect. The app verifies the key against Stripe,
resolves the account id, and auto-provisions the webhook (capturing its
signing secret). A Disconnect button (with confirmation) removes the
provisioned webhook and clears credentials. An optional manual webhook
signing secret supports local development via the Stripe CLI.

Remove the platform OAuth machinery (connection mode, client id/redirect
URI setup) so there is no per-install technical setup and no hosted broker.
Keep the independent Stripe workflow events, including Request Failed which
now also fires on key-verification and webhook-provisioning failures.

Add an explicit OrchardCore.Settings package reference and update the
payments documentation and 3.0.0 changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread src/Modules/CrestApps.OrchardCore.Stripe/Services/StripeConnectService.cs Dismissed
MikeAlhayek and others added 4 commits August 18, 2026 23:19
Introduce CrestApps.OrchardCore.Wizard: a scenario-agnostic multi-step
wizard engine usable via code (definition/handler/display-driver) and via
content items (WizardPart modeled on Flows BagPart) with settings,
completion policy, per-instance authentication, and workflow events.

Also fixes the CI build break by passing TestContext.Current.CancellationToken
in DefaultCustomerContactResolverTests (xUnit1051).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the missing top-level WizardFlow.Edit, WizardFlow.Confirmation and
WizardFlowContentItem shape templates required by the content-driven wizard
UI, and serialize authored step content with JsonSerializer.Serialize instead
of JsonObject.ToJsonString to avoid a MissingMethodException that silently
dropped all steps during session activation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

MikeAlhayek and others added 10 commits September 7, 2026 16:46
Implements the commerce suite review plan: a provider-neutral checkout
engine with durable payment attempts, recurring payment routing for
Stripe and Pay Later, durable subscription agreements with a locked and
idempotent lifecycle, entitlements, tenant provisioning as a durable job,
coupons and discounts applied before tax, and free trials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buying a plan now runs through the Checkout framework like any other
purchase, so there is one path that takes money and one ledger that
records it. The session-based signup flow, its handlers, endpoints,
admin list and subscriber dashboard are removed, along with the
reCaptcha and Tenant Onboarding sub-features and PaymentMethodOptions.

The checkout had no subscription entry point, so this adds one: a plan
contributes its recurring price and setup fee as billing items, the
account step creates and signs in a buyer who is not logged in, and the
content step collects whatever the plan asks for. Subscription reports
are rebased onto the payment ledger and the durable agreement, and
reported per currency rather than summed into a site-wide total.

Trials were plumbed to Stripe but unreachable, so the plan editor gains
a Free Trial Days field.

Standing the suite up and buying something found eight defects that the
unit tests could not, because in each case the page returned 200 and
quietly did the wrong thing:

- A concealed step's charges vanished from the invoice, so a
  subscription checkout totalled 0.00 and completed for nothing.
- Subscriptions, coupons and provisioning jobs were never declared in
  StoreCollectionOptions, so their document tables were never created.
- Five editors rendered empty because a sealed view model cannot be
  proxied, including the payment method list.
- The checkout scripts never loaded: OrchardCore.ResourceManagement is
  not transitive through DisplayManagement, so the tags were literal.
- Two admin screens threw because a partial sat outside Views/Shared.
- A promotion code was silently dropped and the customer charged full
  price, and the invoice was never rebuilt to apply it.
- The handler hooks that stop payment being reached early were declared
  but never raised.
- The invoice was built in the site currency, not the item's.

Each has a regression test; two read the source tree so a missing
collection or package reference fails the build, not a purchase.

Verified in a browser end to end: plan creation, the public plan list,
checkout, Pay Later payment, the durable agreement, transaction and
payment ledgers, coupon apply and redemption, the reports, an anonymous
purchase that creates and signs in an account, and a plan's role
entitlement reaching the buyer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading the finished implementation against the plan, rather than
against its intent, found defects the browser run could not show,
because each appears only over time, at a gateway, or under failure.

Money:
- A free trial could never complete: the gateway reported zero
  collected and reconciliation refused it as a short payment. A deferred
  first cycle now expects nothing; the gateway is still told the real
  price and the deferral.
- First-cycle coupons were display-only, and the Stripe recurring price
  was derived from the first attempt, so a discount would have recurred
  forever. The recurring price now comes from the plan's cycle amount;
  the first-cycle reduction is a single-use Stripe coupon and a smaller
  first Pay Later debt.
- Billing-cycle limits were ignored at Stripe for inline prices, so a
  plan sold as three cycles billed until cancelled. Inline agreements
  carry cancel_at for the end of the last cycle.

Lifecycle:
- Gateway renewals never advanced the local agreement; a subscription
  cycle payment now records the renewal idempotently against the period
  the gateway named.
- Offline agreements were never renewed; the lifecycle sweep advances
  any agreement whose provider has no gateway, and converts an ended
  offline trial into its first billed cycle.
- Webhook and sweep completion was blocked for signed-in buyers because
  the account step read the request's user, which a webhook lacks. It
  now reads the checkout's owner.

Engine:
- A fulfilment failure after payment lost its own status write (YesSql
  discards everything written after a cancel) and was never retried.
  Confirmed payments are committed before fulfilment; a fulfilment that
  throws leaves the checkout for the sweep instead of marking it failed.
- CheckoutSession had no YesSql identity, so saving it after a commit
  inserted a second document; a completed checkout existed twice.
- Abandoned checkouts never expired. ICheckoutEngine.ExpireAsync and a
  session lifetime setting release what a provider was holding, never a
  checkout that collected money.
- A begin that failed part-way committed after its lock; a provisioning
  claim was not committed before the slow work; the status endpoint did
  not check ownership; a checkout submitted before any payment was begun
  was moved to a state its own page refused to show.

Each defect has a regression test, including a SQLite-backed store test
for the identity. Verified by an HTTP-driven purchase against a fresh
site: agreement created, debt recorded, role granted, one document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Exercised the checkout, subscription and refund paths against a real Stripe
test account, using Stripe test clocks to advance through real billing cycles.
That confirmed trials, fixed-cycle limits, first-cycle coupons, gateway
renewals, dunning and refunds end to end, and surfaced four defects that a
fake gateway cannot show.

A plan with a setup fee could not be paid by card at all. The browser
tokenizes one card for the whole checkout and confirms both obligations with
it, but the one-time payment intent was created with no customer while the
agreement had attached that card to one, so Stripe refused the confirmation.
The two obligations now resolve a single customer keyed by the checkout, and
the client's provider data reaches the one-time path as well as the recurring
one.

Cancelling never reached the gateway: CancelRecurringAsync was implemented by
both providers and called by nothing, so a subscriber who cancelled kept being
charged. Suspending had the same shape, and worse, claimed "billing was
suspended" while collection continued. A lifecycle handler now pushes both to
the provider, with pause expressed at Stripe through pause_collection.

A gateway notification could also quietly undo a local suspension, because a
gateway that suspends collection still reports the agreement as active. A
local suspension, like a local cancellation, is now treated as a decision the
gateway cannot express, and synchronizations name their source so a change the
gateway reported is never sent back to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sold an Orchard Core site end to end on a live installation: a plan carrying
the tenant-onboarding part, a buyer naming their site and administrator during
checkout, a real Stripe card, and the provisioning sweep building the tenant.

Nothing gave the new tenant a database, so setup refused every attempt with
"DatabaseProvider setting is required" and no purchased site could ever be
created. The database new sites are created on is now configured once under
tenant onboarding and defaults to Sqlite, so selling a site works before
anything is configured; sites sharing a database each get a table prefix
derived from their name.

A job that failed once could then never succeed. Setup registers the tenant
before it creates anything, so a failed attempt left the name behind in the
running process and every retry failed with a name collision the previous
attempt had invented. An uninitialized shell left by a failed attempt is no
longer read as a name that is taken.

A setup failure reported as a message rather than an exception also left
nothing in the log, and the job keeps only the newest message, so the first
attempt's reason — usually the real one — was lost. Every failed attempt is now
logged with its own reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plan task 1.1. The durable payment attempt and refund ledger lived in Checkout
while the module that reports on it, settles from it, and administers it is
Transactions — so the types now live where they are owned, and the reference
points one way: Checkout writes to the ledger, the ledger knows nothing of the
checkout.

PaymentAttempt, PaymentAttemptState, PaymentRefund, RefundStatus and the two
store contracts move to Transactions.Abstractions; the stores and their index
providers move to Transactions.Core. The financial-document contracts move
there too — a receipt or invoice number belongs with the ledger that issues it —
which empties Commerce.Abstractions, so that project is deleted.

The ledger's index tables deliberately stay in CheckoutMigrations rather than
splitting to TransactionMigrations as the plan sketched: Checkout registers the
index providers, so splitting them would either create tables nothing writes to
or register index providers for tables that were never created.

A new architecture test asserts the direction at the project-reference level:
no Transactions project may reference anything under Checkout except its
abstractions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plan tasks 5.1 to 5.4. A product carried one amount, so selling the same thing
monthly and annually, at a seat price, or for whatever the buyer thinks it is
worth meant a separate product for each.

ProductPricePart adds repeatable prices to any product: one-time or recurring,
each with its own interval, cycle limit, trial, setup fee, optional
buyer-named amount within bounds, optional quantity, and an availability
window. A product with no prices is still sold at the single amount on its
product part, so the part is opt-in and nothing has to be migrated.

The price resolver became the place the rules live. It picks the price the
buyer chose or the default, refuses one that is not on offer, refuses an
amount outside the bounds or a quantity beyond the maximum, and never converts
currency. Those are enforced server-side because every one of them arrives
from a form: a checkout may be started by anyone who can craft the request.

The plan card turns into a chooser when there is a choice to make and stays a
single button when there is not. The chosen price rides on the checkout session
rather than the request, so it survives the buyer leaving the page and is still
there when a provider notification completes the purchase with no browser
involved.

At Stripe a fixed price now becomes one reusable Price identified by a lookup
key that includes the amount, so raising a price creates a new one instead of
silently repricing everyone already subscribed; an amount the buyer named has
no reusable offer behind it and stays inline.

The catalog keeps its own billing-interval vocabulary rather than borrowing the
payments one, so Products still does not depend on the purchasing pipeline —
the architecture test that says so caught the attempt. The checkout handler is
where the two meet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buying a plan on its annual price charged the monthly one. Starting a checkout
builds its steps and their charges from the session, and the chosen price was
put on the session after that had already happened — so the handler that
priced the plan never saw a choice and fell back to the default. The invoice
said "monthly", the checkout completed, and the buyer simply got the wrong
thing at the wrong price. Only buying each price against a real gateway showed
it.

The session is now seeded before the flow is built, which is the rule that
matters: anything deciding what is being bought has to be there when the steps
and their charges are created. A regression test pins the ordering.

The price editor also always offers one blank row. Without it a product with no
prices had no row to type into and nothing for the browser to copy, so the
first price could never be added.

Docs updated for the new prices, the checkout selection, the ledger's move to
Transactions, and a stale line claiming entitlement revocation was missing —
it is not; the handler that grants a plan's roles revokes them when the
agreement stops being current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running it needed no clock to be faked. The sweeps already take IClock; what
they need is an agreement whose next billing date has passed, which is the
state a month of elapsed time produces. Seeding that and letting the real
sweeps run showed both halves: the lifecycle sweep advanced the agreement, and
the Pay Later sweep invoiced the next cycle and noted it on the previous one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answering "does everything have unit tests" honestly: four pieces of the
pricing work did not.

The plan handler had none at all, which is the worst place for a gap — it is
where a plan becomes money, and the wrong branch there is silent. Thirteen
tests now pin the amount, the interval mapping in both directions, quantity as
a subtotal, the setup fee as its own line, trials and cycle limits, which
prices name a reusable offer, the fall back to the plan part, and the refusal
to sell a one-time price as a subscription. Reverting the year mapping fails
two of them.

The Stripe lookup key had none either, though the plan asked for them: the key
changes with the amount and with the interval, and is absent for a buyer-named
amount or a group assembled from several prices.

The editor's validation rules moved out of the display driver into
ProductPriceEditor, because rules buried in a driver cannot be tested without
building a shape harness this suite does not have — and they were worth
testing. The driver is now what a driver should be: it binds a form and renders
the messages those rules produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Subscriptions module

5 participants