magic_payments 0.0.7
magic_payments: ^0.0.7 copied to clipboard
Multi-rail billing for the Magic Framework. One entitlement contract over Stripe on the web and store in-app purchase on mobile.
Changelog #
Unreleased #
0.0.7 #
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.22to^0.0.24andfluttersdk_artisan^0.0.16to^0.0.17. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.24 removesMagicController.onRefreshUI(BREAKING); this package calls it nowhere inlib/ortest/, so nothing here moves with it.test/pubspec_floors_test.dartasserts the new floors. (pubspec.yaml,test/pubspec_floors_test.dart)
0.0.6 #
Fixed #
- An idle
StoreIdentitySync.syncNow()runs in the caller's zone instead of waiting on another zone's microtask queue. 0.0.5 chained every sync onto a stored completed future, and a completed future runs its listeners in the zone it was created in: a sync started inside a widget test's fake-async zone (a team switch inmagic_starter, which awaits the sync) never ran and the switch never returned, once an earlier test had created that future. An idle sync now starts in the caller's own turn; only a sync queued behind one in flight chains, and adetach()during an identify still keeps the next sync behind it. (lib/src/support/store_identity_sync.dart)
0.0.5 #
Added #
StoreIdentitySynckeeps the store rail identified as the paying subject. SetStoreIdentitySync.billableIdto a resolver answering the subject's id (a team or a user; the consumer decides), callattach()once, and everyAuth.stateNotifierchange identifiesPayments.storewith it;syncNow()identifies on demand (after a switch of the paying subject) anddetach()stops. It skips a build without a store rail and a session without a subject, runs syncs one at a time in call order, each reading the subject when its turn comes (so a switch during an identify leaves the rail on the newer subject whatever order the vendor SDK finishes in), identifies a repeated id once, identifies again after a sign-out, and logs aBillingExceptionfrom the rail at error level instead of throwing, retrying that id on the next sync. An unset resolver identifies nothing and logs once at debug level. (lib/src/support/store_identity_sync.dart,doc/basics/rails.md)
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.16to^0.0.22;fluttersdk_artisanstays at^0.0.16, still the newest. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the release this package is verified against.StoreIdentitySyncneeds nothing newer than 0.0.16; of magic 0.0.22's BREAKING changes, onlyAuth.fake()dispatching through the realEventfacade reaches it, from one test, and the suite passes unchanged. (pubspec.yaml,test/pubspec_floors_test.dart)
0.0.4 #
Dependency floors only; the package code is identical to 0.0.3.
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.15to^0.0.16;fluttersdk_artisanstays at^0.0.16, still the newest. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.16 widensfile_pickerto admit 13, wherePlatformFile.length()answers null for an unreadable file; this package does not callPick.test/pubspec_floors_test.dartpins the new magic floor. (pubspec.yaml,test/pubspec_floors_test.dart,README.md,doc/getting-started/installation.md)
0.0.3 #
Dependency floors only; the package code is identical to 0.0.2.
Changed #
- Every sibling floor names this batch's release.
magicmoves^0.0.6to^0.0.15andfluttersdk_artisan^0.0.13to^0.0.16. The old ranges already admitted the new versions, so a freshpub getresolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.15 is breaking in its database layer (a migration may no longer manage its own transaction, andDB.transactionrefuses a callback that closes the transaction itself); nothing in this package calls either, so no code here changes, but an app below magic 0.0.15 no longer resolves this release. (pubspec.yaml,test/pubspec_floors_test.dart)
0.0.2 #
Documentation only; the package code is identical to 0.0.1.
Changed #
- The README described a package that no longer existed. It opened with a
warning that
0.0.1was a scaffold whose "public API is not implemented yet" and whose "exports are empty", which was true of the first commit and of nothing since: the barrels carry the three contracts, the drivers behind them, the entitlement model and the five enums, and 206 tests run against them. That warning was the first thing anyone read on pub.dev, so the package described itself as unusable while being usable. It now carries the ordinary pre-0.1.0caution instead. - The one code sample in the README called a method that does not exist.
Payments.entitlement()isPayments.currentEntitlement(). A reader copying the snippet did not compile. - The README gained the three-role table (which rail exists where, and why a rail is checked rather than assumed) and the actual install commands, rather than a sentence pointing at "your app's config".
0.0.1 #
First release of the package. Everything below is new, so this entry describes the shape rather than a diff.
Added #
-
Billing for a Magic app over more than one rail, behind three contracts instead of one.
BillingServicecarries the five entitlement READS, which are honourable on every platform because the backend is the authority on an entitlement no matter which rail sold the subscription.WebBillingServicecarries the four web writes (checkout, swap, cancel, portal).StoreBillingServicecarries the four store methods (identify, purchase, restore, openStoreManagement). Nine methods in, nine out, none dropped.The split is the point. A single interface forces a build that cannot serve a method to declare it anyway, so the shape it replaces threw
UnsupportedPlatformExceptionfrom four methods on mobile and a billing screen rendered an Upgrade button whose only behaviour was to fail. A caller now asks whether a rail EXISTS (Payments.store != null) and does not render the affordance, instead of rendering one and catching a refusal. -
One compile-time platform seam, and exactly one runtime device check. The drivers resolve through a three-arm conditional import (a stub default, a web arm, an io arm), each arm exposing the same three factory functions because a conditional import resolves a whole FILE. The io arm asks one runtime question of its own, and it is not a smell: its guard is also satisfied on macOS, Windows and Linux, none of which has StoreKit or Play Billing, so "which rails can this BUILD serve" and "does this DEVICE have a store" are two different questions with one mechanism each. Nothing above the factory branches on a platform.
-
A RevenueCat store driver,
RevenueCatStoreService, onpurchases_flutter. It reads one config key,payments.revenuecat.public_sdk_key, and refuses a blank one at purchase time by logging and throwing rather than letting the SDK's own failure surface far from its cause. RevenueCat issues a separate public key per store, andlib/config/payments.dartis Dart rather than JSON, so the published stub resolves it with aswitch (defaultTargetPlatform). -
PaymentsManager, thePaymentsfacade and a service provider. The manager holds one resolved instance per role andextend()swaps any of them, which is how a consumer replaces the store rail with a mediator this package does not ship, and how a test stands in for a driver without mocking a third-party SDK. -
A CLI on
fluttersdk_artisan:payments:install,payments:configureandpayments:doctor, in alib/cli.dartentry point separate from the runtime library so an app that never runs a command does not carry the command tree. Onlydoctoris exposed as an MCP tool, because the other two mutate a consumer's files.doctorreports the store rail's key asabsent,blankordeclaredWITHOUT failing on it. A web-only or desktop-only app is correct without the key, so failing would turn a sound project red; but the driver throws under a customer's finger when it is missing, and passing in silence was measured on a real consumer and was worse. It reports, with the consequence attached. -
PaymentMethod.available, so a consumer stops guessing why a card is missing. Reading a card is the one billing call that dials the rail live, so the producer soft-fails a rail outage into a 200 with every field null, which is byte-identical to a customer who genuinely has no card. The field is the producer's own answer to which of the two it was:falsemeans the rail could not be asked,truewith a nulllast4means there is genuinely no card. It decodes asbool?and an ABSENT key is null, never false, because a backend too old to send it must not be reported as a rail that is down. -
BillingCycle, because a tier is not a price. A vendor sellingproat a monthly rate and again at a discounted annual rate has one tier and two prices, and three places have to agree which is in play: the catalogue shows a figure, the checkout charges one, the renewal line names one. With no cycle on the wire those answers come from three sources and disagree. Measured on a consumer app against a live Stripe test account: the screen offered "Annual, save ~15%" at $29/mo and Stripe charged $34.00 monthly, with the invoice and the renewal date siding with Stripe.So
WebBillingService.checkoutandswapboth take a REQUIREDcycle, with no default. A default would be the same defect wearing a type: the caller showing an annual figure has to say annual, and the compiler is what makes every call site say which.BillingEntitlement.cyclereports what the customer actually bought, resolved server-side from the price their subscription sits on, which is a different fact from whichever column a catalogue toggle happens to be displaying.It is the ONE vocabulary in this package with no fallback member:
BillingCycle.fromWireanswersnullrather than picking a side. Every other enum here degrades to anonecase because "no rail has said" is a state it can express; monthly and annual are the only two cycles there are, so a default is a claim about what somebody is being charged. Null means unknown and a caller has to render it as unknown. -
PlanStatus.isDunning, the question no field on the wire answered. BothpastDueandgracestill GRANT, so a screen readingsubscribedalone cannot tell a paying customer from one whose card has just bounced, and it showed them the same healthy renewal sentence. Measured on a live Stripe test clock: a failed renewal put the subscription inpast_dueand the billing page still read "renews Nov 24, 2026" with no warning anywhere.Deliberately NOT a
grants()mirror, which this enum does not carry: whether a status entitles is the producer's answer and arrives asBillingEntitlement.subscribed. This asks a different question, is the customer's money late, and a client that re-derived entitlement from the status word would be answering the first one twice. -
Seven documentation pages under
doc/, covering installation, configuration, the rails, the drivers, the manager, the service provider and the CLI.
Notes for anyone reading the source #
-
The five reads live in one place,
BillingReadsOverHttp, mixed into both the web and io arms. They were duplicated byte for byte until a review found them, and the duplication was invisible to every gate this package has: only ONE arm compiles per target, so no analyze run and no passing test could ever observe the two copies disagreeing.test/drivers/billing_reads_over_http_test.dartasserts that neither arm declares a read of its own, which is the part that survives a future refactor. -
Neither store rail has processed a transaction. No RevenueCat project or store product exists yet, so the store path is exercised by tests and by nothing else. Treat it as code-complete and unproven.