Medusa v2 plugin for metered usage: an append-only usage event log with pluggable event sinks, batched ingestion, deterministic deduplication and re-derivable aggregation.
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.
Four properties hold it together, and everything else is detail:
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:migrateFrom 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:
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:
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.
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:
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.
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:
A scheduled job (, every minute) sits underneath all of it, for a process that has gone quiet or never recorded anything at all.
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.
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.
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.
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.
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.
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.
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.
| What you see | What it means | What to do |
|---|---|---|
| no result () | the period is not closed yet | do not bill it |
| , | closed, and provably empty | issue no invoice |
| , | closed, all of it inside the allowance | issue no invoice |
| closed, and this is what is owed | invoice it | |
| corrections outweighed the usage | your 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.
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.
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.
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".
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.
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.
| Variable | Default | What 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.
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.
| Method | Path | What |
|---|---|---|
| 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.
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.
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.
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.
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.
Nothing below is designed yet, and each one is a decision that should be made against a real pricing model rather than guessed at:
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.
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.
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:
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.
MIT. See LICENSE.
1your code this plugin your code2 --------- ----------- ---------3 record(event) -> validate, derive key, buffer4 |5 | batch (size or age)6 v7 sink.write() -> append-only event log8 |9 aggregate(window) -> sink.aggregate() -> immutable snapshot10 |11 closePeriod(P) -> aggregate + rate + freeze -> stored result -> invoice it12 |13 +-> usage_period.closed -> your subscribernpm install @zanreal/medusa-usage1// medusa-config.ts2module.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 consumed7 subject: customer.id, // who consumed it, as an opaque id8 quantity: 1, // how much, as a whole number9})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 below10 occurredAt: transfer.finishedAt,11 source: "gateway",12 properties: { region: "eu-central" },13 idempotencyKey: transfer.id, // preferred whenever you have one14 },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.ts2export 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"), // inclusive5 to: new Date("2026-09-01T00:00:00Z"), // exclusive6})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"), // inclusive4 endsAt: new Date("2026-09-01T00:00:00Z"), // exclusive5})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": 28121 }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 missed5 occurredAt: new Date(), // inside September, which is still open6 properties: { late_for_period: closedPeriodId },7})billing: { currency: "PLN", closeDelayMs: 6 * 60 * 60 * 1000, rates: [...] }1// src/subscribers/invoice-closed-period.ts2import { 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 return12 }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.lines19 .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 sink5 // 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 the12 // 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 trigger19 flushIntervalMs: 5000, // the age flush trigger20 maxBufferedEvents: 10000, // ceiling before record applies back pressure21 maxEventsPerCall: 1000, // most events one record call may carry22
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 and27 // a total in two currencies is not a number. ISO 4217, carried onto every28 // result and never resolved against anything.29 currency: "PLN",30
31 // How long after a period ends before it may be closed. Zero allows closing32 // the moment the window is over.33 closeDelayMs: 0,34
35 rates: [36 {37 meter: "api_request", // matched byte for byte against the recorded meter38 unitAmount: 12, // whole minor units, per `perUnits` of the meter39 perUnits: 10_000, // defaults to 140 includedUnits: 1_000_000, // forgiven each period, defaults to 041 },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 test1# 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-alpine6
7# 2. .env (not committed; see .env.template)8cat > .env <<'ENV'9DB_USERNAME=postgres10DB_PASSWORD=postgres11DB_HOST=localhost12DB_PORT=5543713DB_NAME=medusa_usage_dev14DATABASE_URL=postgres://postgres:postgres@localhost:55437/medusa_usage_dev15ENV16
17# 3. Generate, then commit BOTH the migration and the updated snapshot.18pnpm exec medusa plugin:db:generate19
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 another22# repo's container.23docker rm -f usage-migrate-pg && rm -f .env