Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- **`MagicStarterProduct.trialDays` and `MagicStarterBillingController.trialEnd`.** A catalogue product decodes `trial_days` (the free days a purchase starts with, `0` for none, an absent, negative or non-integer value reading `0`), and the controller publishes the date a running trial ends, `null` unless the plan status is `trialing`. A store trial has no `trial_ends_at`, so the entitlement's period end stands in. (`lib/src/models/magic_starter_product.dart`, `lib/src/http/controllers/magic_starter_billing_controller.dart`)
- **The billing screen shows a free trial and the store's introductory offer.** While the customer is trialing, the current plan card gains a "Trial" badge and a trial line that replaces the renewal line: the end date, the days left, and what is billed after (a cancelled trial says it will not renew). A web plan card whose sale product has `trialDays` above zero states "Free for N days. Card required, then ... per ..." above a "Start free trial" button. A store card's disclosure states a free introductory offer only when the store confirms the customer is eligible (`StoreProductOffer.introEligible`), reading the period from its ISO 8601 form; an ineligible customer, a product with no offer or a period that is not one whole unit keeps the plain "per period" line. The billed price stays the large figure on every card, and purchase and checkout calls are unchanged. (`lib/src/ui/views/teams/magic_starter_billing_view.dart`)
- **`magic_starter.billing.trial_badge`, `trial_line_renews`, `trial_line_cycleless`, `trial_line_store`, `trial_line_ends`, `trial_days_left_one`, `trial_days_left_other`, `trial_card_required`, `trial_cta`, `store_disclosure_intro_free` and `period_day`, `period_week`, `period_month`, `period_year` (each `_one` and `_other`)** in `en.stub`. A host that publishes `en.json` adds them to its language file. (`assets/stubs/install/en.stub`, `doc/basics/teams.md`)

### Changed

- **Requires `magic_payments` ^0.0.9 and `magic-starter-laravel` 0.0.23.** `magic_payments` 0.0.9 carries `StoreProductOffer.introEligible`, and the backend's 0.0.23 sends each product row's `trial_days` for the caller. Against an older backend every product reads `trialDays` `0` and no web trial is advertised. (`pubspec.yaml`)

## [0.0.40] - 2026-10-09

Requires `magic_payments` 0.0.8, whose rails purchase by catalogue product key and report `StoreBillingService.lastChangeTiming`, and `magic-starter-laravel` whose `GET /billing/plans` rows carry `products` with `sellable` and `store_ids`.
Expand Down
18 changes: 18 additions & 0 deletions assets/stubs/install/en.stub
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@
"payment_header": "Payment method",
"payment_none": "No card on file",
"payment_update_button": "Update",
"period_day_one": ":count day",
"period_day_other": ":count days",
"period_month_one": ":count month",
"period_month_other": ":count months",
"period_week_one": ":count week",
"period_week_other": ":count weeks",
"period_year_one": ":count year",
"period_year_other": ":count years",
"plan_billing_annual": "billed annually",
"plan_billing_custom": "Tailored to your scale",
"plan_billing_free": "free forever",
Expand Down Expand Up @@ -166,6 +174,7 @@
"store_bound_title": "Your store account is already in use",
"store_disclosure_auto_renew": "The subscription renews automatically unless you cancel it at least 24 hours before the current period ends.",
"store_disclosure_cancel": "Cancel any time in your store's subscription settings.",
"store_disclosure_intro_free": "Free for :intro_period, then :price per :period",
"store_disclosure_period_month": "month",
"store_disclosure_period_year": "year",
"store_disclosure_price": ":price per :period",
Expand All @@ -187,6 +196,15 @@
"toast_failed_text": "Something went wrong. Please try again, and contact support if it keeps happening.",
"toast_switch_title": "Switching to :name",
"toast_upgrade_title": "Upgrading to :name",
"trial_badge": "Trial",
"trial_card_required": "Free for :period. Card required, then :price per :cycle.",
"trial_cta": "Start free trial",
"trial_days_left_one": "1 day left",
"trial_days_left_other": ":count days left",
"trial_line_cycleless": "Free trial ends :date (:left)",
"trial_line_ends": "Free trial ends :date. It will not renew.",
"trial_line_renews": "Free trial ends :date (:left), then :price per :cycle",
"trial_line_store": "Free trial ends :date (:left), then billed through the store that sold this plan.",
"wait_processing": "Processing your purchase. Your plan updates as soon as the store confirms it.",
"wait_takes_effect_on": "Your new plan takes effect on :date."
},
Expand Down
31 changes: 31 additions & 0 deletions doc/basics/teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- [Creating Teams](#creating-teams)
- [Team Switching](#team-switching)
- [The Billable Subject](#billable-subject)
- [Free Trials](#free-trials)
- [Invitation](#invitation)
- [Sending Invitations](#sending-invitations)
- [Accepting Invitations](#accepting-invitations)
Expand Down Expand Up @@ -111,6 +112,36 @@ The team selector dropdown (see [Widget: MSTeamSelector](#widget-msteamselector)

`MagicStarterServiceProvider` reads it at boot and sets `StoreIdentitySync.billableId`; any other value refuses the boot with a `StateError`, because a typo that fell back to `'user'` would bind every purchase to the wrong subject. Identifying on login and restore as well stays your call: add `StoreIdentitySync.attach()` to your provider's `boot()`.

<a name="free-trials"></a>
### Free Trials

The billing screen (`teams.billing`) shows a trial and a store introductory offer without any setup beyond the right versions: `magic_payments` ^0.0.9 and `magic-starter-laravel` 0.0.23, whose catalogue rows carry `trial_days` per product.

`MagicStarterProduct.trialDays` is the free days a purchase of that product starts with, and `0` for none. The backend answers it for the signed-in caller, so `0` also means a trial already used, and a positive value is safe to advertise. `MagicStarterBillingController.trialEnd` is the date a running trial ends (`null` unless the plan status is `trialing`); a store trial has no `trial_ends_at`, so it falls back to the entitlement's current period end.

| Where | What it shows |
|---|---|
| Current plan card | A "Trial" badge beside the plan name, and one line in place of the renewal line: "Free trial ends :date (:left), then :price per :cycle" (a web trial with a known price and cycle), the same without the price and cycle, the same with the store sentence (a store trial), or "Free trial ends :date. It will not renew." once the trial is cancelled |
| Web plan card | Above the call to action, "Free for :period. Card required, then :price per :cycle.", and the call to action reads "Start free trial". Only on a web build, only for a product with `trialDays` above zero, and the billed price stays the large figure |
| Store plan card (disclosure) | "Free for :intro_period, then :price per :period" for a free introductory offer. Only when the store says this customer is eligible (`StoreProductOffer.introEligible`); otherwise the plain "per period" line |

The translator has no plural API, so each count is chosen in Dart: `:count == 1` reads the `_one` key and any other count the `_other` key. The keys, all under `magic_starter.billing` in `en.stub`:

| Key | Default |
|---|---|
| `trial_badge` | `Trial` |
| `trial_line_renews` | `Free trial ends :date (:left), then :price per :cycle` |
| `trial_line_cycleless` | `Free trial ends :date (:left)` |
| `trial_line_store` | `Free trial ends :date (:left), then billed through the store that sold this plan.` |
| `trial_line_ends` | `Free trial ends :date. It will not renew.` |
| `trial_days_left_one`, `trial_days_left_other` | `1 day left`, `:count days left` |
| `trial_card_required` | `Free for :period. Card required, then :price per :cycle.` |
| `trial_cta` | `Start free trial` |
| `store_disclosure_intro_free` | `Free for :intro_period, then :price per :period` |
| `period_day_*`, `period_week_*`, `period_month_*`, `period_year_*` (each `_one` and `_other`) | `:count day`, `:count days`, and so on |

An app that publishes its own `en.json` adds these keys by hand. A store introductory period is read from its ISO 8601 form when it is one whole unit (`P14D`, `P2W`, `P1M`, `P1Y`); anything else (`P1Y2M`) states no offer and keeps the plain line.

<a name="invitation"></a>
## Invitation

Expand Down
19 changes: 19 additions & 0 deletions lib/src/http/controllers/magic_starter_billing_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ class MagicStarterBillingController extends MagicController
bool? _renews;
BillingCycle? _cycle;
PlanStatus _planStatus = PlanStatus.none;
DateTime? _trialEndsAt;
List<MagicStarterPlan> _plans = const <MagicStarterPlan>[];
List<UsageStat> _usage = const <UsageStat>[];
MagicPaginator<Invoice>? _invoicePages;
Expand Down Expand Up @@ -415,6 +416,22 @@ class MagicStarterBillingController extends MagicController
/// that actually decides access.
PlanStatus get planStatus => _planStatus;

/// When the free trial ends, or `null` when the customer is not on one.
///
/// Non-null only while [planStatus] is [PlanStatus.trialing], so a date the
/// producer left on an entitlement that has since converted is never shown as
/// a trial. A web trial reads `trial_ends_at`; a store trial carries no such
/// field and ends at the current period end, which is why the entitlement's
/// period end is the fallback (see `BillingEntitlement.trialEndsAt`).
///
/// `null` while trialing is a real answer too: the producer named no date, and
/// the screen must say nothing about one rather than invent it.
DateTime? get trialEnd {
if (_planStatus != PlanStatus.trialing) return null;

return _trialEndsAt ?? _entitlementSnapshot.currentPeriodEnd;
}

/// The plan catalogue, in the order the backend served it (cheapest first).
///
/// Empty until [loadPlans] resolves, and stays empty (last-known state) on a
Expand Down Expand Up @@ -736,6 +753,7 @@ class MagicStarterBillingController extends MagicController
_renews = null;
_cycle = null;
_planStatus = PlanStatus.none;
_trialEndsAt = null;
_plans = const <MagicStarterPlan>[];
_usage = const <UsageStat>[];
_invoicePages?.dispose();
Expand Down Expand Up @@ -801,6 +819,7 @@ class MagicStarterBillingController extends MagicController
_renews = entitlement.renews;
_cycle = entitlement.cycle;
_planStatus = entitlement.planStatus;
_trialEndsAt = entitlement.trialEndsAt;
_entitlementSnapshot = (
plan: plan,
product: entitlement.productKey,
Expand Down
15 changes: 14 additions & 1 deletion lib/src/models/magic_starter_product.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ class MagicStarterProduct {
/// currency.
final Map<String, MagicStarterWebPrice> webPrices;

/// The free days a purchase of this product starts with, `0` for none.
///
/// The producer answers it for THIS caller: `0` also means a trial this
/// customer has already used, so a positive value is an offer that can be
/// advertised and `0` is never a claim that the product has no trial at all.
/// A web (Stripe) trial takes a card up front. An absent, negative or
/// non-integer `trial_days` decodes to `0`.
final int trialDays;

const MagicStarterProduct({
required this.key,
required this.type,
Expand All @@ -67,6 +76,7 @@ class MagicStarterProduct {
this.sellable = true,
this.storeIds = (appStore: null, play: null),
this.webPrices = const {},
this.trialDays = 0,
});

/// Decodes one `products` entry of a catalogue tier row.
Expand All @@ -77,10 +87,12 @@ class MagicStarterProduct {
///
/// An absent `sellable` reads as `true`, because a producer that predates the
/// flag listed only what it sold. A store id that is not a non-empty string
/// names no store product.
/// names no store product. `trial_days` is read only as a non-negative
/// integer; anything else is no trial rather than a guessed number of days.
factory MagicStarterProduct.fromMap(Map<String, dynamic> map) {
final Object? prices = map['prices'];
final Object? storeIds = map['store_ids'];
final Object? trialDays = map['trial_days'];
final BillingCycle? cycle = BillingCycle.fromWire(map['cycle'] as String?);

return MagicStarterProduct(
Expand All @@ -94,6 +106,7 @@ class MagicStarterProduct {
play: _storeId(storeIds is Map ? storeIds['play'] : null),
),
webPrices: _webPricesFromWire(prices is Map ? prices['web'] : null),
trialDays: trialDays is int && trialDays > 0 ? trialDays : 0,
);
}

Expand Down
Loading
Loading