169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
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;
|
|
}
|
|
}
|