• Модуль интеграций
  • Сообщество
  • Блог
Документация
Плагины и интеграцииВсе расширения для Medusa от сообществаСтартерыЗапускайте проекты быстрее с готовыми решениями
ЭкспертыПодберите специалиста для разработки и развития вашего проекта на MedusaКейсыПосмотрите примеры Medusa в продакшене и успешные внедрения
Представляем готовый к продакшену Medusa DTC Starter от Gorgo

26 августа 2026 г. · Продукт

Gorgo снижает затраты на адаптацию Medusa к локальным рынкам.

Мы разрабатываем плагины интеграции, осуществляем поддержку и развиваем сообщество разработчиков на Medusa в Telegram.

  • Ресурсы Medusa
  • Плагины и интеграции
  • Модуль интеграций
  • Стартеры
  • Эксперты
  • Кейсы
  • Medusa Чат в Telegram
  • Medusa Новости в Telegram
  • Документация Gorgo
  • Связаться с нами
  • TelegramGitHub
Плагины
U

Usage

Плагин Medusa v2 для тарификации по потреблению. Журнал событий только на добавление с подключаемыми приёмниками, пакетной загрузкой, детерминированной дедупликацией и пересчитываемой агрегацией.

npm install @zanreal/medusa-usage
Категория
Другое
Создано
Zanreal
Версия
0.1.1
Последнее обновление
1 неделю назад
Ежемесячные загрузки
Загрузка данных
Звезды на Github
0
npmNPMGitHubGithub

@zanreal/medusa-usage

Metered usage for Medusa v2: an append-only usage event log, batched ingestion, deterministic deduplication, billing periods, rating, and a frozen result you can re-derive a year later.

Full documentation, in English and Polish, is published at https://zanreal.com/docs/oss/medusa-usage and authored in .

Medusa has no metering. Its subscriptions recipe covers fixed-interval subscriptions and says nothing about usage, and there is nothing on npm that fills the gap. This is that missing piece, and only that piece.

It does not do invoicing. It ends at "period P, for subject S, over , rated to T, and here is the frozen breakdown". Turning that into a document, a tax calculation, a payment or a dunning schedule is your application's business, and it always will be. The rates themselves are configuration, not code: the moment a metering plugin has an opinion about what a unit is worth, it stops being usable by anyone whose pricing is not the one it imagined.

Contents

  • What it does
  • Install
  • Recording usage
  • Reading usage
  • Deduplication, in full
  • Why ingestion is batched
  • Why quantities are whole numbers
  • Windows are half-open
  • Billing periods
  • Rating
  • The frozen result
  • A period that closes while events are still arriving
  • Turning a closed period into an invoice
  • Sinks
  • Options
  • Environment variables
  • Admin API
  • Admin UI
  • Corrections
  • Running more than one instance
  • What it deliberately does not do yet
  • Testing
  • Generating a migration
  • License

What it does

Four properties hold it together, and everything else is detail:

  1. The log is append-only. No row is ever updated or deleted. A total computed in March comes out identical in November because it is computed from the same rows, not read from a counter that remembers what someone believed at the time.
  2. Keys are derived, never generated. The same event, sent any number of times, has the same key and occupies at most one row. A retry, a redeploy or a replayed message cannot produce a second count.
  3. Ingestion batches. A round trip per event is not affordable when the database is across the internet, so events are buffered and written in batches.
  4. The sink is a provider. Where the log lives is a plugin option, the same way a fulfillment or notification provider is.
  5. A closed period is frozen. Closing rates the usage once and stores the answer. An invoice is built from that stored row, never from a live query, because a live query answers "what does the log say now" and an invoice needs "what did we charge".

Install

That is the whole configuration. With no options it registers the built-in Postgres sink under the id and writes to the database Medusa already has, so nothing external is needed to start metering.

Then run the migration:

npx medusa db:migrate

Recording usage

From anywhere with the container:

From a workflow, a subscriber or a route, run the workflow instead:

Or over HTTP, for a producer that is not inside this Medusa:

There is no built-in subscriber, and that is not an oversight

An obvious feature would be "map Medusa event X to usage event Y in config". It cannot be built: Medusa binds a subscriber's events from a static export that is evaluated at plugin-load time, before the container - and therefore before this plugin's options - exists. A subscriber cannot learn which events to listen for from configuration.

So the mapping lives in your project, where it is three lines and where it belongs anyway, since only you know what an order or a shipment means in units of your meters:

Reading usage

Store that object next to whatever you billed from it. Ask the same question in a year and compare the two values: equal means the log behind the number is byte-for-byte the same log. Different means something changed, and against tells you whether events were added, removed or restated.

and are outside the digest, so a snapshot re-derived from a migrated log still matches. Everything else is inside it.

The events behind a number:

That is the path for when someone disputes a bill. Showing them another total computed the same way proves nothing; showing them the individual facts does.

Deduplication, in full

Usage that double-counts is worse than usage that is missing. A number that is too small is visible and complainable; a number that is quietly too large becomes an invoice, and nobody finds out until a customer audits it. Every decision below follows from that asymmetry.

An event's key is a SHA-256 over what the event means. Nothing ambient is allowed into it - no random bytes, no , no process id, no hostname, no counter, no arrival order, no database sequence.

With an explicit :

sha256( "usg1" US "explicit" US meter US subject US idempotencyKey )

Without one:

where is U+001F, is the ISO 8601 instant at millisecond precision in UTC, is a decimal integer, and is canonical JSON with its object keys sorted. The result is prefixed , and it is also the primary key of the row - so deduplication is enforced by the database's own primary key, not by a read-then-write that another writer could slip between.

The pieces that make this safe rather than merely tidy:

  • , , and the explicit key are validated to contain no control characters, so U+001F cannot appear inside a field and the join is an injective encoding of its parts.
  • Object key order cannot change a key, because is canonicalised before it is hashed.
  • An absent property bag and an empty one hash identically, because they describe the same event.
  • An explicit key is scoped to . Metering and for the same request id gives two events, not one silently swallowing the other.
  • and are separate domains inside the hash, so the two spaces cannot collide.
  • pins the scheme. If the rules ever change, the prefix changes with them and old rows keep the keys they were written with. Nothing is ever rehashed; rehashing a log is the same as rewriting it.

The vectors are pinned in . Those four hashes are the contract: moving them would mean every key already in a production log stops matching the key the same event derives today, and every one of those events would be counted again.

What the derived form costs. Two genuinely distinct events that are identical in every recorded field, down to the millisecond, collapse into one. That is a real undercount and it is the deliberate side of the trade. If you can produce such events, pass an , or make them distinguishable with an ordinal in , or combine them into one event with a larger - which is usually what was meant.

Why ingestion is batched

validates, keys and buffers, then returns. Batches leave when the buffer fills (, default 500) or when its oldest event reaches (default 5s), whichever comes first.

The reason is deployment reality rather than micro-optimisation: a Medusa talking to a managed Postgres over the internet pays milliseconds of latency per round trip, and a round trip per usage event puts a ceiling on how much you can meter that has nothing to do with your traffic.

The cost, stated plainly. Buffered events live in memory. A loses them. The exposure is bounded by three things - the flush interval, the batch size, and a flush on graceful shutdown - and it errs in the safe direction of the asymmetry above. If that is still unacceptable, set and pay the round trip per call.

Two more properties worth knowing:

  • Back pressure, not dropping. At (default 10 000), waits for a flush instead of growing the buffer. If that flush fails, the error reaches the caller - who can retry safely, because the keys are derived.
  • A failed batch goes back to the front of the queue, ahead of anything newer, so a persistent sink failure cannot starve the oldest usage. Retrying is safe whatever the sink managed to persist before it failed.

A scheduled job (, every minute) sits underneath all of it, for a process that has gone quiet or never recorded anything at all.

Why quantities are whole numbers

must be a safe integer. Not taste: a sum of doubles depends on the order the terms are added, so the same event log could produce two different totals on two different days and both would be defensible. A price computed from that is not.

If what you meter is fractional, meter a smaller unit - bytes rather than gigabytes, milliseconds rather than hours, thousandths of a credit rather than credits - and record the count of those. Convert at the point where you decide what it is worth, which is your code, not this plugin.

The Postgres sink stores as and sums it in the database, which is exact at any size. Reading a total back out refuses rather than rounds if it has grown beyond 2^53, because an approximate number that ends up priced is exactly the failure this plugin exists to prevent.

Windows are half-open

Every window is : inclusive, exclusive. Consecutive periods therefore tile without overlapping - August's is September's , and the event on the boundary is counted exactly once, in September. A closed window would count it in both, which is the same double-counting failure by a different route.

An inverted or zero-length window is refused rather than answered with zero. A zero that came from a typo looks exactly like a customer who used nothing.

Billing periods

A period is a subject and a half-open window. That is the entire model: it is not a subscription, it carries no price, and it does not know what a month is.

, like every other window here, so consecutive periods tile without overlapping and an event on the boundary is billed exactly once, in the later period. A closed window would bill it in both, which is the double-charge this package exists to make impossible.

A period's id is derived, not generated. It is a SHA-256 over the subject and the two instants, so opening the same period twice opens one period and the second call is a no-op the primary key refuses. The consequence worth knowing: a boundary that moves by one millisecond is a different period, with a different id, which can be closed and billed separately. Generate your boundaries deterministically, not from whatever said when a job happened to run.

Nothing closes by itself. This package has no scheduler for periods and should not have one, because only you know whether your cycle is calendar months, thirty days from signup, or something your finance team invented. Opening a period is a statement that a window exists; closing it is a decision you make.

The subscription is free, and that shapes the model

There is no plan price here, no base fee, no minimum commitment and no proration, and there is nowhere to put one. A period's charge is the usage inside it, rated and summed. A customer who consumed nothing owes nothing, and that falls out of the arithmetic rather than out of a special case.

So a subscription, in this model, is only the thing that decides when a period ends. It costs nothing, and it is your data, not this package's.

Rating

A rate is configuration. Set it in and nothing about your meters or your prices is compiled into this package:

The arithmetic, in full:

Money is whole numbers of minor units, for exactly the reason quantities are whole numbers: a sum of doubles depends on the order the terms are added, so one period could rate to two different amounts on two different days and both would be defensible. One of them would be on an invoice. is grosze, cents or pence, as every payment API on earth takes it.

The multiplication and the division are done in , so the intermediate product cannot overflow into an approximation on its way to a division that would have made it exact again. An amount too large to be a safe integer is refused rather than rounded.

is why a rate has a denominator. It defaults to 1, which is the plain "so much per unit" that most rates are. It exists because without it this package would quietly assume every meter is worth at least one minor unit per unit consumed, and a meter counting API requests is not. Priced at a hundredth of a grosz per request, the alternatives would be to invent a meter that counts thousands of requests, losing the raw count the audit path exists to show, or to price in fractions, which is the thing this package refuses to do.

The division truncates toward zero, so rating a credit is exactly the negation of rating the charge it reverses. Flooring would break that, and a correction that does not undo the thing it corrects is worse than no correction. The cost is one dropped fraction of a minor unit per meter per period, in the customer's favour on a charge. A fraction of a grosz cannot be invoiced anyway.

An allowance forgives consumption; it does not create it. A period whose net total is negative, because corrections outweighed usage, passes through untouched rather than being clamped to zero by an allowance it never used. Clamping there would silently swallow money the customer is owed.

What is deliberately not here: tiers, volume breaks, per-subject or per-plan overrides, dimension-priced rates, currency conversion. Each is a real pricing model, none can be designed against products that do not exist yet, and a rate card keyed by anything other than the meter would have to become a query language.

Configuring no block at all is supported and means the plugin meters without rating. Recording, aggregating and listing are unaffected; only closing a period refuses, and it refuses by name rather than rating everything to zero. A period that came to nothing because nobody configured a price looks identical to a period in which nothing was consumed, and those two must not be confused.

The frozen result

Every meter on the rate card is aggregated over the period's window, rated, and written as a line - including the meters that came to nothing, so the result proves each one was looked at rather than leaving you to wonder whether a missing line means zero usage or a forgotten rate.

Every line explains itself. The quantity, the event count, the first and last instants inside the window, the rate that was applied and the digest of the usage snapshot it was rated from. An invoice line nobody can justify is worse than no invoice, so the amount never appears without the arithmetic that produced it, and the arithmetic never appears without a pointer back into the log.

It is stored, unlike a usage snapshot. A snapshot is a value, computed on demand. A result is a row, written once. The moment a number is billed it stops being a question about the log and becomes a fact about what was charged, and those two can drift. So the result is frozen at the instant of closing and read back verbatim afterwards. Build your document from this row and never from a live query.

The row lives in the Medusa database whatever sink the event log uses. Periods are this module's own state, not usage, and the sink contract is three methods over an append-only log and should stay that way. In a Tinybird deployment that means events in Tinybird, periods and their results in Postgres.

Closing twice does not bill twice

The result is inserted under the period's own derived id, and the insert ignores a conflict:

Nothing is read before that write, so there is no window for a retried job or a second worker to slip through. The first call appends the row and reports . Every call after it appends nothing and reports the stored result with - the first answer, not a fresh one, even if the log has moved since.

is the flag to key an invoice off, and only that. It is the one thing that cannot be false twice.

The same guarantee reaches your subscribers, because closing through the workflow emits only on the call that actually closed the period:

So a subscriber that creates an invoice does not have to deduplicate. It is not called twice.

Three states, and telling them apart

What you seeWhat it meansWhat to do
no result ()the period is not closed yetdo not bill it
, closed, and provably emptyissue no invoice
, closed, all of it inside the allowanceissue no invoice
closed, and this is what is owedinvoice it
corrections outweighed the usageyour call: a credit

A free subscription produces the second and third rows routinely. They are not edge cases, and the right response to both is no invoice at all rather than an invoice for zero.

Proving it later

The period is rated again from the log and the two digests are compared. is true when the log behind the number is byte-for-byte the log it was billed from. The rates used are the ones recorded on the stored lines, never the ones in your configuration today, so raising a price cannot make every past period fail to verify, and lowering one cannot quietly claim an old invoice was wrong.

Nothing is written, whatever it finds.

A period that closes while events are still arriving

Late events are real, and the answer here is a decision rather than an accident.

An event that arrives after its period closed is still recorded, in the period it occurred in, and it does not change what was billed. The log accepts it, because the log accepts everything and filters on . The frozen result does not move, because a number that has been invoiced must not.

So the difference surfaces in exactly one place: stops matching, and says by how much, per meter. That is the intended behaviour and not a fault condition. What you do about it is a business decision this package cannot make, but there is only one thing to do that keeps the log honest:

Carry the difference into an open period, as usage. Record a correcting event with an inside the currently open window, pointing at what it is catching up:

August's invoice stands, September's includes the catch-up, and both are derivable from the log. Reopening August would mean editing something a customer has already been sent, which this package has no operation for and should not acquire one.

Reduce how often it happens with . It is a floor on when a period may be frozen, expressed as milliseconds after the window ends:

Zero, the default, allows closing the moment the window is over. Raise it to whatever your slowest producer needs. How late a producer can be is a property of that producer and of the sink underneath it, not of this package, so there is no default that would be right for everyone - but note that closing a period at the stroke of midnight is optimistic in every deployment that has more than one process buffering events, and that the plugin already refuses to close a period whose window has not ended at all.

Turning a closed period into an invoice

This is where the package stops and your application starts. It is deliberately a short piece of code, and none of it belongs in here:

Everything that is missing from that is missing on purpose: tax, invoice numbering, the document itself, the payment, what happens when the payment fails, and what any of it is called in your customer's language. This package cannot know any of it, and a package that guessed would be wrong for everyone except the deployment it was guessed for.

Store the beside whatever you billed. It is the one string that turns "trust us" into "here is the log".

Sinks

The sink is a module provider, exactly like a fulfillment or notification provider: this module owns the interface and the lifecycle, and the implementation is named in .

Why it is provider-shaped rather than a switch or a hardcoded table: where a usage log lives is an infrastructure decision with an enormous range. A store metering thousands of events a month wants them in the Postgres it already runs. A store metering billions wants a column store built for it. Both are metering the same thing, and neither should have to fork a plugin to say so.

The contract is three methods:

and six guarantees, written out in : append only; at most one row per key; safe to retry a batch that failed halfway; filter on event time and never on ingestion time; sum exactly; UTC throughout. There is no update and no delete, and none should be added.

has a worked example of writing one. The built-in Postgres sink is the reference implementation, and is about two hundred lines.

is the second one, in a package of its own so that nothing in here has to know what Tinybird is. It is worth reading if you are writing a third: a column store gives none of the guarantees a primary key does, and the package documents exactly which of them it rebuilds in the read path and which stay eventual.

Options

Every option is validated at boot. A plugin with nowhere to put events is not a quiet no-op - it is silent data loss - so misconfiguration fails the boot rather than disabling the plugin.

Environment variables

VariableDefaultWhat it does
Schedule of the buffer flush job.

It is an environment variable rather than a plugin option because Medusa evaluates a scheduled job's at plugin-load time, before the container - and therefore this plugin's options - exists.

Admin API

Every route is under and authenticated by Medusa's default. A machine producer is a first-class caller and uses an admin API key, which can be rotated and revoked; there is deliberately no unauthenticated ingestion route, because an unauthenticated way to write to a billing input is a way for anyone to change someone's bill.

MethodPathWhat
Sink, ingestion settings, buffer, last flush
Record one event or a batch. 202.
The events behind an aggregate, paged.
A snapshot for one meter and window.
Periods, newest first. Filter by subject, status, end.
Open a period. Idempotent.
The period, and its frozen result if it has one.
Rate it and freeze it. Idempotent.
Rate it again from the log and compare.

returns the derived key of every event, which is what makes a client retry safe: the same body returns the same keys and the log gains nothing the second time.

is safe to retry for the same reason: says whether this call was the one that rated the period, and the body carries the stored result either way. It runs the workflow, so a host's subscribers hear about the close exactly once.

is where to look first when a meter looks wrong. A rising with a is a sink problem. A of zero with no usage arriving is a producer problem. Its field is the configured rate card, or null when the plugin only meters - which is the first thing to check when a period refuses to close.

The listing route takes the query a billing run makes: is every period that is over and has not been billed.

Admin UI

One route, Usage, in the admin sidebar at . It ships with the plugin and needs no configuration: register the plugin and the screen is there.

It answers three questions and deliberately nothing else.

  • Is anything being recorded at all? Which sink is in effect, whether the last flush worked, what is waiting in the buffer, and - separately, because it is a different question - whether any usage exists on each meter in the chosen window. A healthy pipe with nothing in it is a normal state, and so is a meter with a total while the buffer is failing to flush.
  • What did one subject consume? A subject and a half-open UTC window at the top, a row per meter with the quantity, the event count and the snapshot digest, and the individual events behind any of them one click away. That last one is the audit path: what settles a dispute is the facts the total was summed from, not another total computed the same way.
  • Is this period closed, and does it verify? The periods list, with the reason each open one can or cannot be closed yet - a missing rate card, or a window that is still accruing - so a billing run does not discover it as a rejected request. Opening a period shows the frozen result and its digest, and offers behind a confirmation and without one.

Every total, amount and count on the screen is rendered exactly as the endpoints above sent it; amounts are converted from minor currency units by moving the decimal point through the digits, never by dividing, so a figure there cannot drift from the figure that was billed. There are no charts, the screen cannot open a period - which periods exist is the one thing this package cannot decide for you - and it does not enumerate meters, because the plugin records whatever meter name a producer sends and keeps no registry of them. The meter list is the rate card plus whatever you type in.

The screen talks to the same origin the admin is served from. A plugin's admin extensions are built into a bundle before a host ever sees them, so a backend on a separate origin is a deployment it cannot be pointed at.

Corrections

You do not edit a usage event. If usage was recorded wrongly, append its reversal:

The window's total moves, goes up rather than down, and the digest changes - all of which is what an auditor should see. A silently edited row is not.

A correction whose falls inside a period that has already been closed does not change what that period was billed: the frozen result is what was charged and it does not move. It will make stop matching, which is how you find out. See A period that closes while events are still arriving for what to do about it.

Running more than one instance

Each process buffers its own events, and flushes only the buffer of the process serving the request. So a window should be closed before it is snapshotted, by at least . Billing yesterday's usage some time after midnight is fine; billing the last five seconds of it is not.

This is a property of running several processes, not of this plugin, and pretending otherwise would be worse than saying it. Deduplication is unaffected: keys are global and the sink keeps one row per key however many processes wrote it.

The same applies to closing a period, which is a snapshot with money attached: is where you say how long to wait, and closing at the stroke of midnight is optimistic in any deployment with more than one process buffering events.

What it deliberately does not do yet

Nothing below is designed yet, and each one is a decision that should be made against a real pricing model rather than guessed at:

  • Anything above a per-meter rate. Tiers, volume breaks, minimum commitments, proration when a period is cut short, per-subject or per-plan overrides, and currency conversion. What exists today is a whole-number rate per meter, an optional allowance, and a sum.
  • Invoicing, tax and payment. Not "not yet" but "not ever": see the section on turning a closed period into an invoice for where the line is and what it costs you to be on the other side of it.
  • Scheduled closing. Which periods exist, and when, is the one thing a package that does not know your billing cycle cannot decide. Open them and close them from a job of your own.
  • Limits and quotas. Refusing or throttling a request once a subject has passed an allowance, which needs a fast read path that the aggregate query is not.
  • Rollups. Aggregating from raw events stays honest indefinitely, but not fast indefinitely. When it stops being fast the answer is a materialised rollup that is re-derivable from the log, not a mutable counter.

Testing

Unit tests throughout, with no database. What is worth asserting here lives above the database - what a key is derived from, what the buffer does with a batch that fails, whether a snapshot is taken over a flushed buffer - and all of it is observable against fakes.

The one thing fakes cannot cover is whether Postgres really behaves as the sink assumes. That was verified by hand against Postgres 16 while the migration was written: the multi-row really does return only the rows it appended, over is exact, matches containment and skips rows whose properties are null, and the cursor resumes exactly where the previous page ended. The unit tests pin that the provider keeps generating those statements.

Generating a migration

Requires a local Postgres. Always generate rather than hand-writing, so stays authoritative. CI enforces this: it regenerates against a throwaway Postgres and fails on a dirty tree.

Use this exact container name and port. They are recorded here so the next person reuses them rather than hunting for a free port - two people independently picking "the next free port" is how one of them ends up deleting the other's container.

Create and destroy it within the same task, so it never outlives the migration it was for.

Releasing

Publishing happens only from , and there is no second path. npm provenance is a signed statement about where a tarball was built and from which commit, and only a cloud CI run holding an OIDC identity can produce one. An from a laptop would put a version on npm carrying no provenance, and a published version cannot be replaced afterwards, only deprecated. in makes that local publish fail rather than quietly succeed without it.

To cut a release:

  1. Move the entries in CHANGELOG.md under a heading for the new version, dated.
  2. Bump in on .
  3. Publish a GitHub Release whose tag is , exactly.

The workflow refuses to publish when the tag disagrees with , or when that version is already on the registry. A release marked as a prerelease on GitHub publishes under the dist-tag, so never resolves to a release candidate.

Authentication is an repository secret: a granular access token with write permission on this package. npm's trusted publishing (OIDC, with nothing stored in GitHub) cannot cover the first publish, because npmjs.com only offers the trusted publisher form on a package that already exists. Once the first version is up, add one under the package's settings on npmjs.com - GitHub Actions, owner , repository , workflow , environment - and then delete the secret. The workflow needs no edit for that: npm attempts the OIDC exchange first and falls back to the token only when the exchange fails.

License

MIT. See LICENSE.

Еще в этой категории

Посмотреть все
Другое
Gati logo

Gati

От Devx Commerce

Синхронизируйте Medusa с Gati ERP

Загрузка данных
npm
Другое
Product Reviews logo

Product Reviews

От Lambda Curry

Добавляйте рейтинги, отзывы и модерацию товаров

Загрузка данных
GitHubnpm
Другое
Variant Images logo

Variant Images

От Betanoir

Организуйте и загружайте варианты изображений в Medusa

Загрузка данных
GitHubnpm

Еще от этого автора

Посмотреть все
Другое
F

Fx pricing

От Zanreal

Плагин Medusa v2, который рассчитывает цены вариантов в USD и EUR из базовой цены в PLN по среднему курсу таблицы A НБП (центрального банка Польши). Пересчёт идёт сразу при изменении цены и затем ежедневно, с настраиваемой наценкой и защитой ручных правок.

Загрузка данных
GitHubnpm
Другое
A

Allegro

От Zanreal

Плагин Medusa v2 для интеграции с польским маркетплейсом Allegro. Подключение по OAuth, шифрованное хранение токенов, сопоставление предложений и SKU, модели аудита автоматических цен.

Загрузка данных
GitHubnpm
Другое
I

Infakt

От Zanreal

Плагин Medusa v2 для польского документооборота. Выставляет счета в inFakt по оплаченным заказам и передаёт их в KSeF, с устойчивым к сбоям конечным автоматом и интерфейсом оператора для счетов, которым нужна проверка.

Загрузка данных
GitHubnpm
1your code this plugin your code
2 --------- ----------- ---------
3 record(event) -> validate, derive key, buffer
4 |
5 | batch (size or age)
6 v
7 sink.write() -> append-only event log
8 |
9 aggregate(window) -> sink.aggregate() -> immutable snapshot
10 |
11 closePeriod(P) -> aggregate + rate + freeze -> stored result -> invoice it
12 |
13 +-> usage_period.closed -> your subscriber
npm install @zanreal/medusa-usage
1// medusa-config.ts
2module.exports = defineConfig({
3 plugins: [
4 {
5 resolve: "@zanreal/medusa-usage",
6 options: {},
7 },
8 ],
9})
1import { USAGE_MODULE, UsageModuleService } from "@zanreal/medusa-usage/modules/usage"
2
3const usage = container.resolve<UsageModuleService>(USAGE_MODULE)
4
5await usage.record({
6 meter: "api_request", // what was consumed
7 subject: customer.id, // who consumed it, as an opaque id
8 quantity: 1, // how much, as a whole number
9})
1import { recordUsageWorkflow } from "@zanreal/medusa-usage/workflows"
2
3await recordUsageWorkflow(container).run({
4 input: {
5 events: [
6 {
7 meter: "gb_egress",
8 subject: subscriptionId,
9 quantity: 1_500_000, // bytes, not gigabytes: see below
10 occurredAt: transfer.finishedAt,
11 source: "gateway",
12 properties: { region: "eu-central" },
13 idempotencyKey: transfer.id, // preferred whenever you have one
14 },
15 ],
16 },
17})
1curl -X POST https://your-store/admin/usage/events \
2 -H "x-medusa-access-token: $ADMIN_API_KEY" \
3 -H "content-type: application/json" \
4 -d '{"events":[{"meter":"api_request","subject":"cus_01","quantity":1}]}'
1// src/subscribers/meter-deliveries.ts
2export default async function meterDeliveries({ event, container }) {
3 await recordUsageWorkflow(container).run({
4 input: {
5 events: [{
6 idempotencyKey: event.data.id,
7 meter: "delivery",
8 quantity: 1,
9 subject: event.data.customer_id,
10 }],
11 },
12 })
13}
14
15export const config = { event: "delivery.completed" }
1const snapshot = await usage.aggregate({
2 meter: "api_request",
3 subject: customer.id,
4 from: new Date("2026-08-01T00:00:00Z"), // inclusive
5 to: new Date("2026-09-01T00:00:00Z"), // exclusive
6})
1{
2 "version": 1,
3 "meter": "api_request",
4 "subject": "cus_01",
5 "from": "2026-08-01T00:00:00.000Z",
6 "to": "2026-09-01T00:00:00.000Z",
7 "properties": null,
8 "total": 148_302,
9 "eventCount": 148_302,
10 "firstOccurredAt": "2026-08-01T00:04:11.000Z",
11 "lastOccurredAt": "2026-08-31T23:51:07.000Z",
12 "digest": "usnap_9f2c...",
13 "sink": "postgres",
14 "computedAt": "2026-09-01T02:00:00.000Z"
15}
const page = await usage.listEvents({ meter, subject, from, to, limit: 100 })
sha256( "usg1" US "derived" US meter US subject US occurredAt US quantity US source US properties )
1const period = await usage.openPeriod({
2 subject: customer.id,
3 startsAt: new Date("2026-08-01T00:00:00Z"), // inclusive
4 endsAt: new Date("2026-09-01T00:00:00Z"), // exclusive
5})
1options: {
2 billing: {
3 currency: "PLN",
4 rates: [
5 // 12 grosze per 10 000 requests, with the first million each period free.
6 { meter: "api_request", unitAmount: 12, perUnits: 10_000, includedUnits: 1_000_000 },
7 // 5 grosze per gigabyte, from the first one.
8 { meter: "gb_egress", unitAmount: 5 },
9 ],
10 },
11}
1chargeable = total <= 0 ? total : max(total - includedUnits, 0)
2amount = trunc(chargeable * unitAmount / perUnits)
const { result, alreadyClosed } = await usage.closePeriod({ periodId: period.id })
1{
2 "version": 1,
3 "periodId": "ubp_4ddb0a00...",
4 "subject": "cus_01",
5 "from": "2026-08-01T00:00:00.000Z",
6 "to": "2026-09-01T00:00:00.000Z",
7 "currency": "PLN",
8 "lines": [
9 {
10 "meter": "api_request",
11 "quantity": 1_234_567,
12 "eventCount": 1_234_567,
13 "firstOccurredAt": "2026-08-01T00:04:11.000Z",
14 "lastOccurredAt": "2026-08-31T23:51:07.000Z",
15 "usageDigest": "usnap_9f2c...",
16 "includedUnits": 1_000_000,
17 "unitAmount": 12,
18 "perUnits": 10_000,
19 "chargeableQuantity": 234_567,
20 "amount": 281
21 }
22 ],
23 "total": 281,
24 "eventCount": 1_234_567,
25 "digest": "uper_dd025dc6...",
26 "sink": "postgres",
27 "closedAt": "2026-09-01T02:00:00.000Z"
28}
insert into "usage_period_result" (...) values (...) on conflict ("id") do nothing returning "id"
1import { closeBillingPeriodWorkflow } from "@zanreal/medusa-usage/workflows"
2
3await closeBillingPeriodWorkflow(container).run({ input: { periodId } })
1const check = await usage.verifyPeriod(periodId)
2// { matches: true, storedTotal: 281, recomputedTotal: 281, totalDelta: 0, lines: [...] }
1await usage.record({
2 meter: "api_request",
3 subject: customer.id,
4 quantity: 4_120, // what August turned out to have missed
5 occurredAt: new Date(), // inside September, which is still open
6 properties: { late_for_period: closedPeriodId },
7})
billing: { currency: "PLN", closeDelayMs: 6 * 60 * 60 * 1000, rates: [...] }
1// src/subscribers/invoice-closed-period.ts
2import { PERIOD_CLOSED_EVENT } from "@zanreal/medusa-usage/workflows"
3import { USAGE_MODULE, UsageModuleService } from "@zanreal/medusa-usage/modules/usage"
4
5export default async function invoiceClosedPeriod({ event, container }) {
6 const usage = container.resolve<UsageModuleService>(USAGE_MODULE)
7 const result = await usage.getPeriodResult(event.data.id)
8
9 // A free subscription with no usage owes nothing, and nothing is what it gets.
10 if (!result || result.total === 0) {
11 return
12 }
13
14 await yourInvoicingService.create({
15 customerId: result.subject,
16 currency: result.currency,
17 // One invoice line per meter, described in your words, priced in ours.
18 lines: result.lines
19 .filter((line) => line.amount !== 0)
20 .map((line) => ({
21 description: describeMeter(line.meter, line),
22 quantity: line.chargeableQuantity,
23 unitAmount: line.unitAmount,
24 amount: line.amount,
25 })),
26 total: result.total,
27 // Keep the digest. It is what proves the total, months from now.
28 reference: { periodId: result.periodId, digest: result.digest },
29 })
30}
31
32export const config = { event: PERIOD_CLOSED_EVENT }
1interface UsageSinkProvider {
2 write(events: readonly UsageEvent[]): Promise<UsageSinkWriteResult>
3 aggregate(query: UsageAggregateQuery): Promise<UsageAggregateResult>
4 listEvents(query: UsageListQuery): Promise<UsageEventPage>
5}
1{
2 resolve: "@zanreal/medusa-usage",
3 options: {
4 // Which sinks to register. Omit it entirely for the built-in Postgres sink
5 // under the id "postgres".
6 providers: [
7 { resolve: "@zanreal/medusa-usage/providers/postgres", id: "postgres" },
8 ],
9
10 // Which registered sink to write to, by id. Only needed with more than one:
11 // with a single sink there is nothing to disambiguate, and with several the
12 // plugin refuses to guess rather than choosing which log is the real one.
13 sink: "postgres",
14
15 // "buffered" (default) or "immediate".
16 flushMode: "buffered",
17
18 batchSize: 500, // events per write, and the size flush trigger
19 flushIntervalMs: 5000, // the age flush trigger
20 maxBufferedEvents: 10000, // ceiling before record applies back pressure
21 maxEventsPerCall: 1000, // most events one record call may carry
22
23 // What usage is worth. Omit it entirely and the plugin meters without rating:
24 // everything except closing a period works exactly as it did before.
25 billing: {
26 // One currency for the whole card, because a period rates to one total and
27 // a total in two currencies is not a number. ISO 4217, carried onto every
28 // result and never resolved against anything.
29 currency: "PLN",
30
31 // How long after a period ends before it may be closed. Zero allows closing
32 // the moment the window is over.
33 closeDelayMs: 0,
34
35 rates: [
36 {
37 meter: "api_request", // matched byte for byte against the recorded meter
38 unitAmount: 12, // whole minor units, per `perUnits` of the meter
39 perUnits: 10_000, // defaults to 1
40 includedUnits: 1_000_000, // forgiven each period, defaults to 0
41 },
42 ],
43 },
44 },
45}
1await usage.record({
2 meter: "api_request",
3 subject: customer.id,
4 quantity: -12,
5 occurredAt: theOriginalInstant,
6 properties: { correction_of: originalKey },
7})
pnpm test
1# 1. A throwaway Postgres, named after this repo, on this repo's port.
2docker run -d --name usage-migrate-pg \
3 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
4 -e POSTGRES_DB=medusa_usage_dev \
5 -p 55437:5432 postgres:16-alpine
6
7# 2. .env (not committed; see .env.template)
8cat > .env <<'ENV'
9DB_USERNAME=postgres
10DB_PASSWORD=postgres
11DB_HOST=localhost
12DB_PORT=55437
13DB_NAME=medusa_usage_dev
14DATABASE_URL=postgres://postgres:postgres@localhost:55437/medusa_usage_dev
15ENV
16
17# 3. Generate, then commit BOTH the migration and the updated snapshot.
18pnpm exec medusa plugin:db:generate
19
20# 4. Tear down in the same sitting, BY NAME. Never by `--filter publish=<port>`:
21# that matches whatever else happens to be on the port, including another
22# repo's container.
23docker rm -f usage-migrate-pg && rm -f .env