Initial Commit
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
CreatePaymentIntentInput,
|
||||
PaymentIntent,
|
||||
PaymentProvider,
|
||||
PaymentStatus,
|
||||
Refund,
|
||||
RefundInput,
|
||||
} from "@suaveplan/payment";
|
||||
|
||||
interface MoneyLike {
|
||||
readonly amount: bigint;
|
||||
readonly currency: string;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeMoney(amount: bigint, currency: string): MoneyLike {
|
||||
return { amount, currency };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only `PaymentProvider` implementing the real interface from
|
||||
* `@suaveplan/payment`. Stores intents and refunds in two in-memory
|
||||
* maps so the tests can assert against deterministic state. No
|
||||
* external IO.
|
||||
*/
|
||||
export class FakePaymentProvider implements PaymentProvider {
|
||||
public readonly name = "fake";
|
||||
private readonly intents = new Map<string, PaymentIntent>();
|
||||
private readonly idemMap = new Map<string, string>();
|
||||
private seq = 0;
|
||||
public failCreate = false;
|
||||
public returnEmptyOnCreate = false;
|
||||
|
||||
public reset(): void {
|
||||
this.intents.clear();
|
||||
this.idemMap.clear();
|
||||
this.seq = 0;
|
||||
this.failCreate = false;
|
||||
this.returnEmptyOnCreate = false;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
input: CreatePaymentIntentInput
|
||||
): Promise<PaymentIntent> {
|
||||
if (this.failCreate) {
|
||||
throw new Error("simulated provider failure");
|
||||
}
|
||||
if (this.returnEmptyOnCreate) {
|
||||
return null as unknown as PaymentIntent;
|
||||
}
|
||||
if (input.idempotencyKey !== undefined) {
|
||||
const existingId = this.idemMap.get(input.idempotencyKey);
|
||||
if (existingId !== undefined) {
|
||||
const found = this.intents.get(existingId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
this.seq += 1;
|
||||
const id = `pi_${this.seq}`;
|
||||
const intent: PaymentIntent = {
|
||||
id,
|
||||
status: input.captureMethod === "manual" ? "created" : "created",
|
||||
amount: makeMoney(input.amount.amount, input.amount.currency),
|
||||
capturedAmount: makeMoney(0n, input.amount.currency),
|
||||
refundedAmount: makeMoney(0n, input.amount.currency),
|
||||
captureMethod: input.captureMethod ?? "automatic",
|
||||
refunds: [],
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
...(input.description !== undefined
|
||||
? { description: input.description }
|
||||
: {}),
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata }
|
||||
: {}),
|
||||
...(input.idempotencyKey !== undefined
|
||||
? { idempotencyKey: input.idempotencyKey }
|
||||
: {}),
|
||||
};
|
||||
this.intents.set(id, intent);
|
||||
if (input.idempotencyKey !== undefined) {
|
||||
this.idemMap.set(input.idempotencyKey, id);
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
public async confirmIntent(id: string): Promise<PaymentIntent> {
|
||||
return this.updateStatus(id, "authorized");
|
||||
}
|
||||
|
||||
public async captureIntent(
|
||||
id: string,
|
||||
amount?: MoneyLike
|
||||
): Promise<PaymentIntent> {
|
||||
const current = this.mustGet(id);
|
||||
const captureAmt = amount?.amount ?? current.amount.amount;
|
||||
const newStatus: PaymentStatus = "captured";
|
||||
const updated: PaymentIntent = {
|
||||
...current,
|
||||
status: newStatus,
|
||||
capturedAmount: makeMoney(captureAmt, current.amount.currency),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
this.intents.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async cancelIntent(id: string): Promise<PaymentIntent> {
|
||||
return this.updateStatus(id, "canceled");
|
||||
}
|
||||
|
||||
public async refund(input: RefundInput): Promise<Refund> {
|
||||
const current = this.mustGet(input.paymentIntentId);
|
||||
const amt = input.amount?.amount ?? current.capturedAmount.amount;
|
||||
const refundedSoFar = current.refundedAmount.amount + amt;
|
||||
const refund: Refund = {
|
||||
id: `re_${this.seq + 1}`,
|
||||
paymentIntentId: input.paymentIntentId,
|
||||
amount: makeMoney(amt, current.amount.currency),
|
||||
status: "succeeded",
|
||||
...(input.reason !== undefined ? { reason: input.reason } : {}),
|
||||
createdAt: nowIso(),
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata }
|
||||
: {}),
|
||||
};
|
||||
this.seq += 1;
|
||||
const newStatus: PaymentStatus =
|
||||
refundedSoFar >= current.capturedAmount.amount
|
||||
? "refunded"
|
||||
: "partially_refunded";
|
||||
const updated: PaymentIntent = {
|
||||
...current,
|
||||
status: newStatus,
|
||||
refundedAmount: makeMoney(refundedSoFar, current.amount.currency),
|
||||
refunds: [...current.refunds, refund],
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
this.intents.set(current.id, updated);
|
||||
return refund;
|
||||
}
|
||||
|
||||
public async getIntent(id: string): Promise<PaymentIntent> {
|
||||
return this.mustGet(id);
|
||||
}
|
||||
|
||||
private mustGet(id: string): PaymentIntent {
|
||||
const found = this.intents.get(id);
|
||||
if (found === undefined) {
|
||||
throw new Error(`provider has no intent "${id}"`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private updateStatus(id: string, status: PaymentStatus): PaymentIntent {
|
||||
const current = this.mustGet(id);
|
||||
const updated: PaymentIntent = {
|
||||
...current,
|
||||
status,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
this.intents.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Side-effect imports — surface the Hono ContextVariableMap augmentation
|
||||
// (`user: Principal | null`) so tests can `c.set("user", ...)` with the
|
||||
// expected typing.
|
||||
import "@suaveplan/api-plugin-auth";
|
||||
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-payment.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user