Economy Authnet

Commerce engineStable
@wabbit/tome-economy-authnetv0.2.1

Authorize.net payment provider adapter for @wabbit/tome-economy — Accept Hosted checkout + card capture, ARB recurring subscriptions, X-ANET-Signature webhook verification, outbound refunds, and a nightly ARB reconciliation job. A generic high-risk-friendly processor adapter for verticals Stripe's own policy excludes (e.g. cannabis-adjacent commerce) — no vertical-specific logic lives here.

Install
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
  3. Then install:

    npm install @wabbit/tome-economy-authnet

Overview

@wabbit/tome-economy-authnet

Authorize.net PaymentProviderAdapter for `@wabbit/tome-economy` — a generic, high-risk-friendly processor adapter. It contains no cannabis or other vertical-specific strings or logic; a tenant's vertical rules (age gates, content policy, licensing) live in the consuming layer (e.g. @wabbit/tome-directory's DirectoryTenantPolicy), never here.

Layer: domain / family commerce / tier pro (per ARCHITECTURE.md).

Why this package exists

Stripe's own restricted-business policy excludes US businesses where more than 25% of marketing/SaaS services target marijuana sellers or products, outside Australia/Canada/the EU/New Zealand/the UK. A directory or SaaS product built for that vertical is disqualified in writing, by Stripe itself — not an inference, a quote. Worse, Stripe account termination for a cannabis-adjacent business is well-precedented (2018 ancillary-business purge across Stripe/Square/PayPal) and the failure mode is catastrophic for a subscription business: card tokens live inside Stripe and are not portable, so every active subscription breaks at once, at an unpredictable moment, usually after there is enough MRR to be worth reviewing.

Authorize.net's GatewayOnly posture is the structural fix: it is a processor-neutral gateway that keeps the CIM (Customer Information Manager) card vault portable across acquiring banks/ISOs. If the underwriting ISO changes, the integration — this package — does not. The full analysis lives in internal payments research; the short version is below under "ISO onboarding notes."

Why no Authorize.net SDK

Authorize.net's official Node SDK (authorizenet on npm) wraps a large, AnetApiSchema-generated model surface derived from the legacy XML API, has a poor ESM story, and a thin maintenance cadence. In JSON mode, the actual API this adapter needs is ten flat request/response shapes over plain HTTP. src/api/client.ts implements exactly those, typed, over fetch — no dependency, no generated-code surface to keep in sync, and a runtime that matches every other adapter in this monorepo (StripeAdapter also talks to its provider directly, just via the official stripe SDK since Stripe's is genuinely good).

Install

pnpm add @wabbit/tome-economy-authnet

| Peer | Range | |---|---| | payload | >=3.67.0 | | @wabbit/tome-core | >=1.14.0 <2.0.0 | | @wabbit/tome-economy | >=0.10.0 <1.0.0 (needs the open PaymentProvider union + webhookSignatureHeader + cancelSubscription/refund — prerequisite PR (b), 2026-09-06) |

API surface

Single export subpath (. only). Everything ships from the main barrel — no ./server split, matching @wabbit/tome-economy's own posture.

| Group | Exports | |---|---| | Adapter | AuthorizeNetAdapter, createAuthorizeNetAdapter, AuthorizeNetPeriodEndCancelUnsupportedError (+ AuthorizeNetAdapterConfig, CardCaptureSessionResult, FinalizeSubscriptionFromProfileArgs, FinalizeSubscriptionFromProfileResult). Beyond the PaymentProviderAdapter contract (createCheckoutSession, createSubscriptionSession, createPortalSession, cancelSubscription, refund, handleWebhook, retrieveSession) it adds finalizeSubscriptionFromProfile, createCardCaptureSession (step 1 returned as { token, formActionUrl, customerProfileId } — the reused profile id, or the one it just created for a first-time subscriber) and acceptHostedPageUrl() | | Portable plan / metadata helpers | encodeArbPlan, decodeArbPlan, encodeArbMetadata, decodeArbMetadata (+ AuthorizeNetEnvironment, ArbPlan, ArbLifecycleMetadata types) | | JSON API client (advanced use — most consumers only need AuthorizeNetAdapter) | AuthorizeNetClient, AuthorizeNetApiError, centsToDecimalString, decimalStringToCents (+ AuthorizeNetClientConfig, AuthorizeNetMessage, AuthorizeNetMessages, AuthorizeNetEnvelope types) | | Webhooks | verifyAuthorizeNetSignature, mapAuthorizeNetWebhookEvent (+ AuthorizeNetWebhookPayload type) | | Jobs | createArbReconciliationJob (+ ArbReconciliationJobConfig, ArbReconciliationJobResult, ArbDowngradeEvent types) |

Quickstart

// app/api/webhooks/authorizenet/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { createAuthorizeNetAdapter } from '@wabbit/tome-economy-authnet'
import { createStripeWebhookHandler } from '@wabbit/tome-economy' // name is historical — the handler is provider-generic

export async function POST(req: Request) {
  const adapter = createAuthorizeNetAdapter({
    apiLoginId: process.env.AUTHNET_API_LOGIN_ID!,
    transactionKey: process.env.AUTHNET_TRANSACTION_KEY!,
    signatureKey: process.env.AUTHNET_SIGNATURE_KEY!, // hex, as copied from the merchant interface
    environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
  })
  const payload = await getPayload({ config })
  return createStripeWebhookHandler({ adapter, payload })(req)
}

environment is required ('sandbox' | 'production'); an optional fetch can be injected for tests or alternate runtimes. A signatureKey that is not an even-length hex string makes every webhook fail verification, and handleWebhook throws on a bad signature or a non-JSON body.

createStripeWebhookHandler already reads adapter.webhookSignatureHeader ?? 'stripe-signature' (prerequisite PR (b)) — AuthorizeNetAdapter declares 'X-ANET-Signature', so no route change is needed beyond swapping the adapter.

Construct the adapter inside the server action / route body, same rule StripeAdapter documents — do not pass an adapter instance across the Server Action serialization boundary.

Accept Hosted embedding

Authorize.net has no equivalent to a Stripe Checkout redirect URL. createCheckoutSession and createSubscriptionSession both return a one-time, 15-minute hosted-page token (as sessionId) plus the Accept Hosted page URL for the adapter's configured environment (as url, also available via adapter.acceptHostedPageUrl()). The client must POST the token as a hidden form field named token to that URL — a GET redirect will not work.

The standard pattern is an iframe + AcceptUI.js (Authorize.net's client-side communicator library) or a plain auto-submitting form into an iframe:

<iframe name="authnet-hosted" id="authnet-hosted"></iframe>
<form method="POST" action="{{ url }}" target="authnet-hosted" id="authnet-form">
  <input type="hidden" name="token" value="{{ sessionId }}" />
</form>
<script>document.getElementById('authnet-form').submit()</script>

The iframe communicator posts a message event back to iframeCommunicatorUrl (passed as successUrl by this adapter) on completion/cancel/resize — see Authorize.net's Accept Hosted docs for the message contract.

The two-step subscription flow

Unlike Stripe's single mode: 'subscription' Checkout session, Authorize.net has no server-side price object and no one-call hosted subscription page. createSubscriptionSession / finalizeSubscriptionFromProfile split card capture from subscription creation:

sequenceDiagram
    participant Buyer
    participant Site
    participant Adapter as AuthorizeNetAdapter
    participant AuthNet as Authorize.net
    Buyer->>Site: chooses a paid tier
    Site->>Adapter: createSubscriptionSession({ metadata: { arbPlan } })
    Adapter->>AuthNet: createCustomerProfileRequest (if new)
    Adapter->>AuthNet: getHostedPaymentPageRequest (authOnly, $0.01, createProfile:true)
    Adapter-->>Site: { sessionId: token, url }
    Site-->>Buyer: renders Accept Hosted iframe, POSTs token
    Buyer->>AuthNet: enters card in the hosted iframe
    AuthNet-->>Site: return/receipt postMessage with transId
    Site->>Adapter: finalizeSubscriptionFromProfile({ transactionId, plan, metadata })
    Adapter->>AuthNet: getTransactionDetailsRequest (resolve CIM profile ids)
    Adapter->>AuthNet: ARBCreateSubscriptionRequest (profile-based)
    Adapter-->>Site: { subscriptionId, customerProfileId, customerPaymentProfileId }
  1. Step 1 — card capture (`createSubscriptionSession`). Ensures a CIM Customer Profile exists (creating one if existingCustomerProfileId is omitted), then requests an Accept Hosted token for a nominal $0.01 `authOnlyTransaction` with profile.createProfile: true. Authorize.net attaches the card the buyer enters to the customer profile. This adapter never captures or voids that $0.01 auth explicitly — Authorize.net auto-voids an uncaptured auth-only transaction at end of day.
  2. Step 2 — ARB create (`finalizeSubscriptionFromProfile`), called from the return/receipt handler once the hosted-page transaction settles. Resolves the CIM profile ids the capture transaction minted (getTransactionDetailsRequest) and creates the ARB subscription against them (ARBCreateSubscriptionRequest). Its arguments are { transactionId, name, plan, metadata, invoiceNumber? } — name is required (it is what the Authorize.net admin UI shows), plan is an ArbPlan, and metadata is { userId, productId, productType }.

createSubscriptionSession throws when metadata.arbPlan is missing or does not decode — there is no fallback price. Pass existingCustomerProfileId for a returning buyer to skip creating a second CIM profile.

Stripe-shaped semantics that differ here: retrieveSession(id) expects a settled transaction id (transId from the receipt), not the hosted-page token; cancelSubscription only cancels immediately — ARB has no deferred cancel, and the cancel webhook ends access at once — so it requires atPeriodEnd: false and throws AuthorizeNetPeriodEndCancelUnsupportedError for true or an omitted value (the interface default is a soft cancel, and cancelSubscriptionAction defaults to one too, so pass atPeriodEnd: false there explicitly); refund requires the original transaction to have a CIM profile attached, and refunds the full authorized amount when no amount is passed; createPortalSession(customerProfileId, returnUrl) returns a hosted profile-management page URL.

The stripePriceId mismatch

@wabbit/tome-economy's CreateSubscriptionSessionArgs.stripePriceId: string is Stripe-shaped — it assumes a server-side price object. Authorize.net ARB defines its plan inline on every create call. AuthorizeNetAdapter.createSubscriptionSession therefore ignores stripePriceId and instead reads a portable plan from metadata.arbPlan, a JSON string produced by this package's encodeArbPlan():

import { encodeArbPlan } from '@wabbit/tome-economy-authnet'

await adapter.createSubscriptionSession({
  stripePriceId: 'unused', // ignored — see below
  customerEmail,
  successUrl,
  cancelUrl,
  metadata: {
    arbPlan: encodeArbPlan({ amountCents: 2900, currency: 'USD', interval: 'months', intervalLength: 1 }), // 'days': 7–365, 'months': 1–12; optional totalOccurrences, trialOccurrences, trialAmountCents, startDate
  },
})

This is a documented adapter-specific convention, not a core contract change. A future PaymentProviderAdapter.createSubscription() seam is expected to add a portable plan field; when that lands, this adapter should read plan directly and this convention goes away — metadata itself needs no migration.

Metadata resolution — an honest limitation

@wabbit/tome-economy's subscription lifecycle webhooks (subscription.renewed/cancelled/payment_failed) all carry userId/productId/productType, sourced from Stripe's first-class metadata bag. Authorize.net has no equivalent generic bag on a subscription. This adapter's best effort: finalizeSubscriptionFromProfile embeds metadata (via encodeArbMetadata) into the ARB subscription's order.description field (255-char limit — ample for three short ids as JSON).

Whether a given Authorize.net webhook event echoes that field back is UNVERIFIED. Authorize.net's public docs describe subscription lifecycle webhook payloads as thin ({ entityName, id }), and only transaction (authcapture) events are documented as carrying order/invoice fields. mapAuthorizeNetWebhookEvent checks every plausible payload location and falls back to empty strings when none match — the same graceful degradation createStripeWebhookHandler already applies to any adapter (it logs a warning and skips the ledger write for that event when userId/productId are empty).

Correctness does not depend on this resolving. The subscription's providerRef (the ARB subscriptionId, returned synchronously from finalizeSubscriptionFromProfile — unlike Stripe, there is no webhook round-trip required to learn it) lets the consumer write the Subscriptions row with the correct account/price at creation time, before any webhook fires at all. The nightly reconciliation job below is the actual correctness backstop, and it reads the Subscriptions row directly — it needs no metadata recovered from a webhook.

Webhook event map

CONFIRMED from developer.authorize.net/api/reference/features/webhooks.html and internal payments research.

| Authorize.net eventType | Normalized WebhookEvent | |---|---| | net.authorize.customer.subscription.created | subscription.renewed (activation) | | net.authorize.payment.authcapture.created (with a resolvable subscription id) | subscription.renewed, with amountCents/currency when the transaction carries authAmount | | net.authorize.payment.authcapture.created (no subscription id — an ordinary transaction) | unknown | | net.authorize.customer.subscription.failed | subscription.payment_failed | | net.authorize.customer.subscription.suspended | subscription.payment_failed | | net.authorize.customer.subscription.cancelled | subscription.cancelled | | net.authorize.customer.subscription.terminated | subscription.cancelled | | net.authorize.customer.subscription.expired | subscription.cancelled | | net.authorize.customer.subscription.expiring | unknown — no normalized union member for a renewal-reminder signal exists yet; a consumer wanting a "card expiring soon" email should special-case the raw eventType before calling the mapper. v1.5 trigger: add a subscription.expiring member to @wabbit/tome-economy's WebhookEvent union. | | net.authorize.payment.refund.created | refund | | anything else | unknown |

Important caveat carried from the research: when a subscription is suspended for a failed payment, it does not self-heal on a later successful card — it must be explicitly un-suspended. Neither this adapter nor the reconciliation job re-activates a suspended/past_due row automatically; see the job's own doc comment.

Nightly ARB reconciliation job

import { createArbReconciliationJob } from '@wabbit/tome-economy-authnet'
import { onSubscriptionPaymentFailedDispatch, onSubscriptionCancelDispatch } from '@wabbit/tome-economy'

const runArbReconciliation = createArbReconciliationJob({
  payload,
  apiLoginId: process.env.AUTHNET_API_LOGIN_ID!,
  transactionKey: process.env.AUTHNET_TRANSACTION_KEY!,
  environment: 'production',
  onDowngrade: async (event) => {
    const { userId, productId, productType } = (event.metadata ?? {}) as Record<string, string>
    if (!userId || !productId) return
    if (event.mappedStatus === 'past_due') {
      await onSubscriptionPaymentFailedDispatch({ subscriptionId: event.providerRef, userId, productId, productType, failedAt: new Date().toISOString() })
    } else {
      await onSubscriptionCancelDispatch({ subscriptionId: event.providerRef, userId, productId, productType, accessEndsAt: new Date().toISOString() })
    }
  },
})

The job scans the economy subscriptions collection (subscriptionsSlug) for provider: 'authorizenet' rows in active / trialing / past_due, pageSize (default 100) at a time, collecting every matching row before it changes any (a row downgraded to canceled leaves the filter, so paging while writing would skip rows), and asks ARB for each one's status. ARB suspended maps to past_due; terminated / canceled / expired map to canceled. The job writes the new `status` onto the row itself, then calls onDowngrade — the hook is for side effects such as revoking access, not for the status write. It returns { checked, downgraded, errors }; a per-row failure is collected in errors rather than aborting the run.

Wire it into a Payload scheduled job (or any cron runner) at a nightly cadence — the research explicitly recommends this because ARB's webhook coverage for silent declines is documented but unverified; polling ARBGetSubscriptionStatus for every authorizenet row is cheap insurance the Stripe integration never needed. The factory needs a live payload, which payload.config.ts does not have at module scope, so build the runner inside the task handler. A Payload jobs.tasks example:

// payload.config.ts
jobs: {
  tasks: [
    {
      slug: 'authnet-arb-reconciliation',
      handler: async ({ req }) => {
        const runArbReconciliation = createArbReconciliationJob({
          payload: req.payload,
          apiLoginId: process.env.AUTHNET_API_LOGIN_ID!,
          transactionKey: process.env.AUTHNET_TRANSACTION_KEY!,
          environment: 'production',
          onDowngrade, // as above
        })
        const result = await runArbReconciliation()
        if (result.errors.length) req.payload.logger.error({ result }, 'ARB reconciliation errors')
        return { output: result }
      },
    },
  ],
},

then schedule it nightly via whatever mechanism triggers Payload jobs in your deployment (a cron-triggered payload jobs:run, a queue schedule, etc — this package intentionally does not bundle a scheduler).

ISO onboarding notes (from internal payments research)

This adapter is gateway-only — it does not choose or configure the underwriting ISO. Two evaluated for a cannabis-ancillary media business (advertising subscriptions billed to licensed businesses, not a cannabis retailer itself):

  • Easy Pay Direct (primary candidate) — the only ISO of eleven evaluated whose own marketing page names "ancillary cannabis services" as a served category, offering Authorize.net as a gateway choice with 1–4 business day approval on clean files. Its reputation signal is the weakest part of the recommendation (Trustpilot 3.1–4.3 across sources; recurring complaint theme is rolling-reserve transparency) — get the reserve percentage, hold period, and release schedule in writing before signing, and treat a refusal to do so as a decline.
  • PaymentCloud (fallback) — strongest independent reputation of the set evaluated (BBB A+, Trustpilot 4.7/~695 reviews), also lands on Authorize.net/NMI/USAePay. Because both land on the same gateway, switching between them is a commercial swap, not a rebuild — apply to both in parallel.
  • GatewayOnly / vault portability: Authorize.net markets "GatewayOnly" precisely for this scenario — keep the gateway and CIM vault, swap the underlying processor/ISO if underwriting terms change or an account is declined. This is the direct structural answer to Stripe's card-token-lockup failure mode; it is why this package targets Authorize.net's API surface rather than an ISO-specific one.
  • Rolling reserve caveat: budget for a reserve (an affiliate-sourced range of 5–10% held 3–6 months circulates; this is not a quoted rate from either ISO) as the planning assumption, and treat anything better as upside.
  • Hedge: card-on-file should not be the only rail for larger accounts. ACH/invoice avoids card-network category rules entirely; @wabbit/tome-economy's existing 'manual' provider member already supports an invoice-and-reconcile path that can ship before any merchant account is approved.

None of the above is legal or financial advice; it summarizes point-in-time research (2026-09-06) and every rate/fee/reserve figure is explicitly unverified pending a written quote.

Sandbox setup

  1. Create a free Authorize.net sandbox account.
  2. From the sandbox merchant interface: Account > Settings > Security Settings > API Credentials & Keys for the API Login ID + Transaction Key; General Security Settings > Signature Key for the webhook signature key.
  3. Register a webhook endpoint under Account > Settings > Webhooks, subscribing to at least: net.authorize.customer.subscription.created, .suspended, .cancelled, .terminated, .expired, net.authorize.payment.authcapture.created, net.authorize.payment.refund.created.
  4. Sandbox test card numbers are published at developer.authorize.net/hello_world/testing_guide.html — use 4111111111111111 for a generic approved Visa.
  5. Set environment: 'sandbox' on the adapter config; this routes every API call to apitest.authorize.net and the Accept Hosted page URLs to their test.authorize.net equivalents.

Testing

pnpm --filter @wabbit/tome-economy-authnet test runs the Vitest suite with no network: pass a stub fetch in the adapter or job config (both accept one) and assert on the JSON request bodies it receives. verifyAuthorizeNetSignature(rawBody, header, signatureKey) and mapAuthorizeNetWebhookEvent(payload) are pure, so webhook tests need neither a server nor a sandbox account.

Security

apiLoginId, transactionKey, and signatureKey are never logged by this package. Webhook verification uses HMAC-SHA512 over the exact raw request body (no JSON.parse/JSON.stringify round-trip before verifying — that can change key order/whitespace and produce a false mismatch), keyed by the hex-decoded Signature Key, compared via @wabbit/tome-core/utilities/timingSafeEqual (constant-time, hash-then-compare) rather than a naive ===.

Exports

  • @wabbit/tome-economy-authnet

Changelog

v0.2.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.0minor

dde4ed0: **BREAKING:** `cancelSubscription` now rejects a period-end cancel instead of silently cancelling at once; `createCardCaptureSession` returns the new profile id; reconciliation no longer skips rows. **Migration:** pass `atPeriodEnd: false` to `cancelSubscription` (and to `cancelSubscriptionAction`, whose default is a soft cancel). Authorize.net ARB can only cancel immediately and its cancel webhook ends access at once, so a soft cancel now throws `AuthorizeNetPeriodEndCancelUnsupportedError` (newly exported) rather than becoming a hard one. - `createCardCaptureSession` returned the caller's `existingCustomerProfileId` (so `undefined` for a first-time subscriber) instead of the customer profile it had just created. - `createArbReconciliationJob` collects every candidate row before downgrading any. A row downgraded to `canceled` leaves the status filter, and paging over the shrinking result set skipped rows for the rest of the run.

  • dde4ed0: **BREAKING:** `cancelSubscription` now rejects a period-end cancel instead of silently cancelling at once; `createCardCaptureSession` returns the new profile id; reconciliation no longer skips rows. **Migration:** pass `atPeriodEnd: false` to `cancelSubscription` (and to `cancelSubscriptionAction`, whose default is a soft cancel). Authorize.net ARB can only cancel immediately and its cancel webhook ends access at once, so a soft cancel now throws `AuthorizeNetPeriodEndCancelUnsupportedError` (newly exported) rather than becoming a hard one. - `createCardCaptureSession` returned the caller's `existingCustomerProfileId` (so `undefined` for a first-time subscriber) instead of the customer profile it had just created. - `createArbReconciliationJob` collects every candidate row before downgrading any. A row downgraded to `canceled` leaves the status filter, and paging over the shrinking result set skipped rows for the rest of the run.
v0.1.1patch

b2470a2: ARB reconciliation job: replace the two inline `as CollectionSlug` casts with core's `typedSlug()` (core is already a required peer). No runtime change.

  • b2470a2: ARB reconciliation job: replace the two inline `as CollectionSlug` casts with core's `typedSlug()` (core is already a required peer). No runtime change.
v0.1.0minor

5e7ad1f: New package: `@wabbit/tome-economy-authnet` — an Authorize.net `PaymentProviderAdapter` for `@wabbit/tome-economy`. A generic, high-risk-friendly processor adapter (no cannabis or other vertical-specific logic) for verticals Stripe's own restricted-business policy excludes. - `AuthorizeNetAdapter` implements `provider: 'authorizenet'`, `webhookSignatureHeader: 'X-ANET-Signature'`, `createCheckoutSession` (Accept Hosted one-time), the two-step subscription flow (`createSubscriptionSession` + `finalizeSubscriptionFromProfile`), `createPortalSession` (hosted profile page), `handleWebhook`, `cancelSubscription`, `refund`, `retrieveSession` - `AuthorizeNetClient` — a typed JSON client over `fetch` for the Authorize.net API (no SDK dependency; see README "Why no SDK"), covering customer profiles, Accept Hosted, ARB subscriptions, and transaction refunds, with the JSON gateway's BOM-response quirk and documented field-order requirements handled - Webhook signature verification (`verifyAuthorizeNetSignature`, HMAC-SHA512 via `@wabbit/tome-core/utilities/timingSafeEqual`) and event mapping (`mapAuthorizeNetWebhookEvent`) onto the existing normalized `WebhookEvent` union - `createArbReconciliationJob` — a nightly job that polls `ARBGetSubscriptionStatus` for every active `authorizenet` `Subscriptions` row, since ARB's webhook coverage for silent declines is documented but unverified - No Authorize.net SDK dependency; peers `@wabbit/tome-core`, `@wabbit/tome-economy`, `payload`

  • 5e7ad1f: New package: `@wabbit/tome-economy-authnet` — an Authorize.net `PaymentProviderAdapter` for `@wabbit/tome-economy`. A generic, high-risk-friendly processor adapter (no cannabis or other vertical-specific logic) for verticals Stripe's own restricted-business policy excludes. - `AuthorizeNetAdapter` implements `provider: 'authorizenet'`, `webhookSignatureHeader: 'X-ANET-Signature'`, `createCheckoutSession` (Accept Hosted one-time), the two-step subscription flow (`createSubscriptionSession` + `finalizeSubscriptionFromProfile`), `createPortalSession` (hosted profile page), `handleWebhook`, `cancelSubscription`, `refund`, `retrieveSession` - `AuthorizeNetClient` — a typed JSON client over `fetch` for the Authorize.net API (no SDK dependency; see README "Why no SDK"), covering customer profiles, Accept Hosted, ARB subscriptions, and transaction refunds, with the JSON gateway's BOM-response quirk and documented field-order requirements handled - Webhook signature verification (`verifyAuthorizeNetSignature`, HMAC-SHA512 via `@wabbit/tome-core/utilities/timingSafeEqual`) and event mapping (`mapAuthorizeNetWebhookEvent`) onto the existing normalized `WebhookEvent` union - `createArbReconciliationJob` — a nightly job that polls `ARBGetSubscriptionStatus` for every active `authorizenet` `Subscriptions` row, since ARB's webhook coverage for silent declines is documented but unverified - No Authorize.net SDK dependency; peers `@wabbit/tome-core`, `@wabbit/tome-economy`, `payload`