@droplinked_inc/payment-intent
v0.1.0
Published
Hardened rebuild of [email protected]. Server-side PaymentIntent state machine + idempotency + cross-PSP defence. Pairs with @droplinked_inc/payment-hub.
Downloads
63
Readme
@droplinked_inc/payment-intent
Server-side PaymentIntent state machine, idempotency, and refund accounting for the droplinked platform. Pairs with
@droplinked_inc/payment-hub(PSP orchestration).
This package is the durable record of a payment's lifecycle. It does
not call out to PSPs and it does not verify webhook signatures
— payment-hub and its adapters own that. What it owns is the legal
state graph, idempotent creation, refund accounting under bigint
arithmetic, optimistic-concurrency-protected updates, and a tamper-
evident audit history.
See THREAT_MODEL.md for the eight P0 threats
this package mitigates and how.
State machine
requires_payment_method ─► requires_confirmation ─► processing
│ │ │
│ │ ├─► succeeded
│ │ │ │
│ │ │ ├─► partially_refunded ──► refunded
│ │ │ └─► refunded
│ │ │
│ │ └─► failed
│ │
└────────── canceled ◄─────┘Terminal states (refunded, failed, canceled) have no outgoing
transitions. Every transition is gated by the allowlist in
state-machine.ts and goes through one private codepath
(commitTransition).
Install
pnpm add @droplinked_inc/payment-intentQuick start
import {
PaymentIntentService,
MemoryIdempotencyStore,
MemoryPaymentIntentRepository,
} from '@droplinked_inc/payment-intent';
const svc = new PaymentIntentService({
// production: inject Mongo/Redis-backed implementations
repository: new MemoryPaymentIntentRepository(),
idempotencyStore: new MemoryIdempotencyStore(),
});
// 1. Create
const intent = await svc.create({
orderId: 'ord_abc',
provider: 'stripe',
intentType: 'payment',
amountMinorUnits: 1999n, // $19.99 in cents
currency: 'USD',
idempotencyNonce: 'checkout-button-click-uuid',
});
// 2. Walk it through the machine
await svc.confirm(intent.id);
await svc.markProcessing(intent.id);
await svc.markSucceeded(intent.id);
// 3. Refund (partial-first, then top-up)
await svc.refund({
intentId: intent.id,
provider: 'stripe',
amountMinorUnits: 999n,
currency: 'USD',
refundEventId: 're_xyz',
reason: 'partial-refund',
});
// state is now `partially_refunded`
await svc.refund({
intentId: intent.id,
provider: 'stripe',
amountMinorUnits: 1000n,
currency: 'USD',
refundEventId: 're_xyz_2',
reason: 'complete-refund',
});
// state is now `refunded` — terminalWebhook integration
The caller — usually a route handler in the droplinked backend — is
responsible for cryptographic signature verification (use
@droplinked_inc/payment-hub adapters). Once verified, hand the
structured event to applyWebhookEvent:
const updated = await svc.applyWebhookEvent({
intentId: 'pi_…',
provider: 'stripe',
eventId: 'evt_…', // provider-side id (used for replay defence)
targetState: 'succeeded',
reason: 'payment_intent.succeeded',
});The package will:
- Reject if the event's
providerdoes not match the intent's (cross-PSP defence —ProviderMismatchError). - Reject if
eventIdwas already applied (WebhookEventReplayError). - Reject if the implied state is unreachable from the current state
(
InvalidStateTransitionError). - Append the event to the immutable
historyarray, bumpversion, and OCC-update the record.
Idempotency
Two derivation strategies:
- Deterministic (
idempotencyNonceor full input set):SHA-256(length-prefix(orderId, provider, amount, currency, nonce)). Re-submitting the same logical operation returns the same intent. - Random (no nonce supplied): 256 bits of CSPRNG entropy, hex.
Implementations of IdempotencyStore for Redis/Mongo must enforce
atomic put-if-absent semantics. See src/idempotency.ts.
Errors
Every typed error extends PaymentIntentError, which extends Error.
Error messages are passed through redactSecrets() so credential-
shaped tokens never leak through the surface.
| Class | Code |
|--------------------------------|----------------------------|
| InvalidStateTransitionError | INVALID_STATE_TRANSITION |
| ProviderMismatchError | PROVIDER_MISMATCH |
| CurrencyMismatchError | CURRENCY_MISMATCH |
| IdempotencyConflictError | IDEMPOTENCY_CONFLICT |
| RefundExceedsChargeError | REFUND_EXCEEDS_CHARGE |
| ConcurrentUpdateError | CONCURRENT_UPDATE |
| WebhookEventReplayError | WEBHOOK_REPLAY |
| WebhookSignatureError | WEBHOOK_SIGNATURE |
| PaymentIntentNotFoundError | NOT_FOUND |
| PaymentIntentValidationError | VALIDATION |
Test coverage
90% lines / 95% branches / 100% funcs across the package. Property-based tests (via
fast-check) cover state-machine invariants and refund-sum accumulation.
Development
pnpm typecheck
pnpm lint
pnpm test
pnpm test:coverage
pnpm buildLicense
MIT — see monorepo root.
