@wopr-network/platform-core 1.14.7 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/dist/account/deletion-executor-repository.d.ts +2 -2
- package/dist/account/deletion-executor-repository.js +5 -5
- package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
- package/dist/billing/{payram → crypto}/charge-store.d.ts +17 -13
- package/dist/billing/{payram → crypto}/charge-store.js +21 -18
- package/dist/billing/crypto/charge-store.test.js +64 -0
- package/dist/billing/crypto/checkout.d.ts +18 -0
- package/dist/billing/crypto/checkout.js +35 -0
- package/dist/billing/crypto/checkout.test.js +71 -0
- package/dist/billing/crypto/client.d.ts +39 -0
- package/dist/billing/crypto/client.js +72 -0
- package/dist/billing/crypto/client.test.js +100 -0
- package/dist/billing/crypto/index.d.ts +9 -0
- package/dist/billing/crypto/index.js +5 -0
- package/dist/billing/crypto/types.d.ts +61 -0
- package/dist/billing/crypto/types.js +24 -0
- package/dist/billing/crypto/webhook.d.ts +34 -0
- package/dist/billing/crypto/webhook.js +107 -0
- package/dist/billing/crypto/webhook.test.js +266 -0
- package/dist/billing/index.d.ts +1 -1
- package/dist/billing/index.js +2 -2
- package/dist/billing/payment-processor.d.ts +3 -3
- package/dist/config/provider-endpoints.d.ts +4 -0
- package/dist/config/provider-endpoints.js +10 -6
- package/dist/credits/credit-ledger.d.ts +3 -3
- package/dist/credits/credit-ledger.js +3 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/schema/credits.js +1 -1
- package/dist/db/schema/{payram.d.ts → crypto.d.ts} +17 -13
- package/dist/db/schema/crypto.js +25 -0
- package/dist/db/schema/index.d.ts +1 -1
- package/dist/db/schema/index.js +1 -1
- package/dist/fleet/node-repository.d.ts +1 -3
- package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
- package/dist/monetization/crypto/index.d.ts +4 -0
- package/dist/monetization/crypto/index.js +2 -0
- package/dist/monetization/crypto/webhook.d.ts +24 -0
- package/dist/monetization/crypto/webhook.js +88 -0
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/observability/pagerduty.test.js +1 -0
- package/dist/security/key-validation.test.js +65 -8
- package/drizzle/migrations/0004_crypto_charges.sql +25 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -3
- package/src/account/deletion-executor-repository.ts +6 -6
- package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
- package/src/billing/crypto/charge-store.test.ts +81 -0
- package/src/billing/{payram → crypto}/charge-store.ts +28 -25
- package/src/billing/crypto/checkout.test.ts +93 -0
- package/src/billing/crypto/checkout.ts +48 -0
- package/src/billing/crypto/client.test.ts +132 -0
- package/src/billing/crypto/client.ts +86 -0
- package/src/billing/crypto/index.ts +15 -0
- package/src/billing/crypto/types.ts +83 -0
- package/src/billing/crypto/webhook.test.ts +340 -0
- package/src/billing/crypto/webhook.ts +136 -0
- package/src/billing/index.ts +2 -2
- package/src/billing/payment-processor.ts +3 -3
- package/src/config/provider-endpoints.ts +10 -6
- package/src/credits/credit-ledger.ts +3 -3
- package/src/db/index.ts +1 -2
- package/src/db/schema/credits.ts +1 -1
- package/src/db/schema/crypto.ts +30 -0
- package/src/db/schema/index.ts +1 -1
- package/src/fleet/node-repository.ts +8 -3
- package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
- package/src/monetization/crypto/index.ts +23 -0
- package/src/monetization/crypto/webhook.ts +115 -0
- package/src/monetization/index.ts +23 -21
- package/src/monetization/repository-types.ts +2 -2
- package/src/observability/pagerduty.test.ts +1 -0
- package/src/security/key-validation.test.ts +74 -8
- package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
- package/dist/billing/payram/charge-store.test.js +0 -64
- package/dist/billing/payram/checkout.d.ts +0 -15
- package/dist/billing/payram/checkout.js +0 -24
- package/dist/billing/payram/checkout.test.js +0 -74
- package/dist/billing/payram/client.d.ts +0 -7
- package/dist/billing/payram/client.js +0 -15
- package/dist/billing/payram/client.test.js +0 -52
- package/dist/billing/payram/index.d.ts +0 -8
- package/dist/billing/payram/index.js +0 -4
- package/dist/billing/payram/types.d.ts +0 -40
- package/dist/billing/payram/webhook.d.ts +0 -19
- package/dist/billing/payram/webhook.js +0 -71
- package/dist/billing/payram/webhook.test.d.ts +0 -7
- package/dist/billing/payram/webhook.test.js +0 -249
- package/dist/db/schema/payram.js +0 -21
- package/dist/monetization/payram/charge-store.test.d.ts +0 -1
- package/dist/monetization/payram/charge-store.test.js +0 -64
- package/dist/monetization/payram/checkout.test.d.ts +0 -1
- package/dist/monetization/payram/checkout.test.js +0 -73
- package/dist/monetization/payram/client.test.d.ts +0 -1
- package/dist/monetization/payram/client.test.js +0 -52
- package/dist/monetization/payram/index.d.ts +0 -4
- package/dist/monetization/payram/index.js +0 -2
- package/dist/monetization/payram/webhook.d.ts +0 -17
- package/dist/monetization/payram/webhook.js +0 -71
- package/dist/monetization/payram/webhook.test.d.ts +0 -7
- package/dist/monetization/payram/webhook.test.js +0 -247
- package/src/billing/payram/charge-store.test.ts +0 -84
- package/src/billing/payram/checkout.test.ts +0 -99
- package/src/billing/payram/checkout.ts +0 -40
- package/src/billing/payram/client.test.ts +0 -62
- package/src/billing/payram/client.ts +0 -21
- package/src/billing/payram/index.ts +0 -14
- package/src/billing/payram/types.ts +0 -44
- package/src/billing/payram/webhook.test.ts +0 -320
- package/src/billing/payram/webhook.ts +0 -94
- package/src/db/schema/payram.ts +0 -26
- package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
- package/src/monetization/payram/charge-store.test.ts +0 -84
- package/src/monetization/payram/checkout.test.ts +0 -98
- package/src/monetization/payram/client.test.ts +0 -62
- package/src/monetization/payram/index.ts +0 -20
- package/src/monetization/payram/webhook.test.ts +0 -327
- package/src/monetization/payram/webhook.ts +0 -97
- /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
- /package/dist/billing/{payram/types.js → crypto/webhook.test.d.ts} +0 -0
- /package/dist/monetization/{payram/cents-credits-boundary.test.d.ts → crypto/__tests__/webhook.test.d.ts} +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
3
|
+
describe("BTCPayClient", () => {
|
|
4
|
+
it("createInvoice sends correct request and returns id + checkoutLink", async () => {
|
|
5
|
+
const mockResponse = { id: "inv-001", checkoutLink: "https://btcpay.example.com/i/inv-001" };
|
|
6
|
+
const fetchSpy = vi
|
|
7
|
+
.spyOn(globalThis, "fetch")
|
|
8
|
+
.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
|
|
9
|
+
const client = new BTCPayClient({
|
|
10
|
+
apiKey: "test-key",
|
|
11
|
+
baseUrl: "https://btcpay.example.com",
|
|
12
|
+
storeId: "store-abc",
|
|
13
|
+
});
|
|
14
|
+
const result = await client.createInvoice({
|
|
15
|
+
amountUsd: 25,
|
|
16
|
+
orderId: "order-123",
|
|
17
|
+
buyerEmail: "test@example.com",
|
|
18
|
+
});
|
|
19
|
+
expect(result.id).toBe("inv-001");
|
|
20
|
+
expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
|
|
21
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
22
|
+
const [url, opts] = fetchSpy.mock.calls[0];
|
|
23
|
+
expect(url).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices");
|
|
24
|
+
expect(opts?.method).toBe("POST");
|
|
25
|
+
const headers = opts?.headers;
|
|
26
|
+
expect(headers.Authorization).toBe("token test-key");
|
|
27
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
28
|
+
const body = JSON.parse(opts?.body);
|
|
29
|
+
expect(body.amount).toBe("25");
|
|
30
|
+
expect(body.currency).toBe("USD");
|
|
31
|
+
expect(body.metadata.orderId).toBe("order-123");
|
|
32
|
+
expect(body.metadata.buyerEmail).toBe("test@example.com");
|
|
33
|
+
expect(body.checkout.speedPolicy).toBe("MediumSpeed");
|
|
34
|
+
fetchSpy.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
it("createInvoice includes redirectURL when provided", async () => {
|
|
37
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-002", checkoutLink: "https://btcpay.example.com/i/inv-002" }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
}));
|
|
40
|
+
const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "s" });
|
|
41
|
+
await client.createInvoice({ amountUsd: 10, orderId: "o", redirectURL: "https://app.example.com/success" });
|
|
42
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body);
|
|
43
|
+
expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
|
|
44
|
+
fetchSpy.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
it("createInvoice throws on non-ok response", async () => {
|
|
47
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
|
|
48
|
+
const client = new BTCPayClient({ apiKey: "bad-key", baseUrl: "https://btcpay.example.com", storeId: "s" });
|
|
49
|
+
await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow("BTCPay createInvoice failed (401)");
|
|
50
|
+
fetchSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
it("getInvoice sends correct request", async () => {
|
|
53
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-001", status: "Settled", amount: "25", currency: "USD" }), {
|
|
54
|
+
status: 200,
|
|
55
|
+
}));
|
|
56
|
+
const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
|
|
57
|
+
const result = await client.getInvoice("inv-001");
|
|
58
|
+
expect(result.status).toBe("Settled");
|
|
59
|
+
expect(fetchSpy.mock.calls[0][0]).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices/inv-001");
|
|
60
|
+
fetchSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("loadCryptoConfig", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
delete process.env.BTCPAY_API_KEY;
|
|
66
|
+
delete process.env.BTCPAY_BASE_URL;
|
|
67
|
+
delete process.env.BTCPAY_STORE_ID;
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.unstubAllEnvs();
|
|
71
|
+
});
|
|
72
|
+
it("returns null when BTCPAY_API_KEY is missing", () => {
|
|
73
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
74
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
75
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it("returns null when BTCPAY_BASE_URL is missing", () => {
|
|
78
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
79
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
80
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
it("returns null when BTCPAY_STORE_ID is missing", () => {
|
|
83
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
84
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
85
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
it("returns config when all env vars are set", () => {
|
|
88
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
89
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
90
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
91
|
+
expect(loadCryptoConfig()).toEqual({
|
|
92
|
+
apiKey: "test-key",
|
|
93
|
+
baseUrl: "https://btcpay.test",
|
|
94
|
+
storeId: "store-1",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it("returns null when all env vars are missing", () => {
|
|
98
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { CryptoChargeRecord, ICryptoChargeRepository } from "./charge-store.js";
|
|
2
|
+
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
3
|
+
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
4
|
+
export type { CryptoConfig } from "./client.js";
|
|
5
|
+
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
6
|
+
export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
|
|
7
|
+
export { mapBtcPayEventToStatus } from "./types.js";
|
|
8
|
+
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
9
|
+
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
2
|
+
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
3
|
+
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
4
|
+
export { mapBtcPayEventToStatus } from "./types.js";
|
|
5
|
+
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** BTCPay Server invoice states (Greenfield API v1). */
|
|
2
|
+
export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
|
|
3
|
+
/** Options for creating a crypto payment session. */
|
|
4
|
+
export interface CryptoCheckoutOpts {
|
|
5
|
+
/** Internal tenant ID. */
|
|
6
|
+
tenant: string;
|
|
7
|
+
/** Amount in USD (minimum $10). */
|
|
8
|
+
amountUsd: number;
|
|
9
|
+
}
|
|
10
|
+
/** Webhook payload received from BTCPay Server (InvoiceSettled event). */
|
|
11
|
+
export interface CryptoWebhookPayload {
|
|
12
|
+
/** BTCPay delivery ID (for deduplication). */
|
|
13
|
+
deliveryId: string;
|
|
14
|
+
/** Webhook ID. */
|
|
15
|
+
webhookId: string;
|
|
16
|
+
/** Original delivery ID (same as deliveryId on first delivery). */
|
|
17
|
+
originalDeliveryId: string;
|
|
18
|
+
/** Whether this is a redelivery. */
|
|
19
|
+
isRedelivery: boolean;
|
|
20
|
+
/** Event type (e.g. "InvoiceSettled", "InvoiceProcessing", "InvoiceExpired"). */
|
|
21
|
+
type: string;
|
|
22
|
+
/** Unix timestamp. */
|
|
23
|
+
timestamp: number;
|
|
24
|
+
/** BTCPay store ID. */
|
|
25
|
+
storeId: string;
|
|
26
|
+
/** BTCPay invoice ID. */
|
|
27
|
+
invoiceId: string;
|
|
28
|
+
/** Invoice metadata (echoed from creation). */
|
|
29
|
+
metadata: Record<string, unknown>;
|
|
30
|
+
/** Whether admin manually marked as settled (InvoiceSettled only). */
|
|
31
|
+
manuallyMarked?: boolean;
|
|
32
|
+
/** Whether customer overpaid (InvoiceSettled only). */
|
|
33
|
+
overPaid?: boolean;
|
|
34
|
+
/** Whether invoice was partially paid (InvoiceExpired only). */
|
|
35
|
+
partiallyPaid?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Configuration for BTCPay Server integration. */
|
|
38
|
+
export interface CryptoBillingConfig {
|
|
39
|
+
/** BTCPay API key (from Account > API keys). */
|
|
40
|
+
apiKey: string;
|
|
41
|
+
/** BTCPay Server base URL. */
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
/** BTCPay store ID. */
|
|
44
|
+
storeId: string;
|
|
45
|
+
}
|
|
46
|
+
/** Result of processing a crypto webhook event. */
|
|
47
|
+
export interface CryptoWebhookResult {
|
|
48
|
+
handled: boolean;
|
|
49
|
+
status: string;
|
|
50
|
+
tenant?: string;
|
|
51
|
+
creditedCents?: number;
|
|
52
|
+
reactivatedBots?: string[];
|
|
53
|
+
duplicate?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Map BTCPay webhook event type string to a CryptoPaymentState.
|
|
57
|
+
*
|
|
58
|
+
* Shared between the core (billing) and consumer (monetization) webhook handlers.
|
|
59
|
+
* Throws on unrecognized event types to surface integration errors early.
|
|
60
|
+
*/
|
|
61
|
+
export declare function mapBtcPayEventToStatus(eventType: string): CryptoPaymentState;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map BTCPay webhook event type string to a CryptoPaymentState.
|
|
3
|
+
*
|
|
4
|
+
* Shared between the core (billing) and consumer (monetization) webhook handlers.
|
|
5
|
+
* Throws on unrecognized event types to surface integration errors early.
|
|
6
|
+
*/
|
|
7
|
+
export function mapBtcPayEventToStatus(eventType) {
|
|
8
|
+
switch (eventType) {
|
|
9
|
+
case "InvoiceCreated":
|
|
10
|
+
return "New";
|
|
11
|
+
case "InvoiceReceivedPayment":
|
|
12
|
+
case "InvoiceProcessing":
|
|
13
|
+
return "Processing";
|
|
14
|
+
case "InvoiceSettled":
|
|
15
|
+
case "InvoicePaymentSettled":
|
|
16
|
+
return "Settled";
|
|
17
|
+
case "InvoiceExpired":
|
|
18
|
+
return "Expired";
|
|
19
|
+
case "InvoiceInvalid":
|
|
20
|
+
return "Invalid";
|
|
21
|
+
default:
|
|
22
|
+
throw new Error(`Unknown BTCPay event type: ${eventType}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ILedger } from "../../credits/ledger.js";
|
|
2
|
+
import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
|
|
3
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
4
|
+
import type { CryptoWebhookPayload, CryptoWebhookResult } from "./types.js";
|
|
5
|
+
export interface CryptoWebhookDeps {
|
|
6
|
+
chargeStore: ICryptoChargeRepository;
|
|
7
|
+
creditLedger: ILedger;
|
|
8
|
+
replayGuard: IWebhookSeenRepository;
|
|
9
|
+
/** Called after credits are purchased — consumer can reactivate suspended resources. Returns reactivated resource IDs. */
|
|
10
|
+
onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Verify BTCPay webhook signature (HMAC-SHA256).
|
|
14
|
+
*
|
|
15
|
+
* BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
|
|
16
|
+
*/
|
|
17
|
+
export declare function verifyCryptoWebhookSignature(rawBody: Buffer | string, sigHeader: string | undefined, secret: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Process a BTCPay Server webhook event.
|
|
20
|
+
*
|
|
21
|
+
* Only credits the ledger on InvoiceSettled status.
|
|
22
|
+
* Uses the BTCPay invoice ID mapped to the stored charge record
|
|
23
|
+
* for tenant resolution and idempotency.
|
|
24
|
+
*
|
|
25
|
+
* Idempotency strategy (matches Stripe webhook pattern):
|
|
26
|
+
* Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
|
|
27
|
+
* checked inside the ledger's serialized transaction.
|
|
28
|
+
* Secondary: `chargeStore.markCredited()` — advisory flag for queries.
|
|
29
|
+
*
|
|
30
|
+
* CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
|
|
31
|
+
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
32
|
+
* Never pass raw cents to the ledger — always go through Credit.fromCents().
|
|
33
|
+
*/
|
|
34
|
+
export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<CryptoWebhookResult>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { Credit } from "../../credits/credit.js";
|
|
3
|
+
import { mapBtcPayEventToStatus } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Verify BTCPay webhook signature (HMAC-SHA256).
|
|
6
|
+
*
|
|
7
|
+
* BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
|
|
8
|
+
*/
|
|
9
|
+
export function verifyCryptoWebhookSignature(rawBody, sigHeader, secret) {
|
|
10
|
+
if (!sigHeader)
|
|
11
|
+
return false;
|
|
12
|
+
const expectedSig = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
13
|
+
const expected = Buffer.from(expectedSig, "utf8");
|
|
14
|
+
const received = Buffer.from(sigHeader, "utf8");
|
|
15
|
+
if (expected.length !== received.length)
|
|
16
|
+
return false;
|
|
17
|
+
return crypto.timingSafeEqual(expected, received);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Process a BTCPay Server webhook event.
|
|
21
|
+
*
|
|
22
|
+
* Only credits the ledger on InvoiceSettled status.
|
|
23
|
+
* Uses the BTCPay invoice ID mapped to the stored charge record
|
|
24
|
+
* for tenant resolution and idempotency.
|
|
25
|
+
*
|
|
26
|
+
* Idempotency strategy (matches Stripe webhook pattern):
|
|
27
|
+
* Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
|
|
28
|
+
* checked inside the ledger's serialized transaction.
|
|
29
|
+
* Secondary: `chargeStore.markCredited()` — advisory flag for queries.
|
|
30
|
+
*
|
|
31
|
+
* CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
|
|
32
|
+
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
33
|
+
* Never pass raw cents to the ledger — always go through Credit.fromCents().
|
|
34
|
+
*/
|
|
35
|
+
export async function handleCryptoWebhook(deps, payload) {
|
|
36
|
+
const { chargeStore, creditLedger } = deps;
|
|
37
|
+
// Replay guard FIRST: deduplicate by invoiceId + event type.
|
|
38
|
+
// Must run before mapBtcPayEventToStatus() — unknown event types throw,
|
|
39
|
+
// and BTCPay retries webhooks on failure. Without this ordering, an unknown
|
|
40
|
+
// event type causes an infinite retry loop.
|
|
41
|
+
const dedupeKey = `${payload.invoiceId}:${payload.type}`;
|
|
42
|
+
if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
|
|
43
|
+
return { handled: true, status: "New", duplicate: true };
|
|
44
|
+
}
|
|
45
|
+
// Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
|
|
46
|
+
const status = mapBtcPayEventToStatus(payload.type);
|
|
47
|
+
// Look up the charge record to find the tenant.
|
|
48
|
+
const charge = await chargeStore.getByReferenceId(payload.invoiceId);
|
|
49
|
+
if (!charge) {
|
|
50
|
+
return { handled: false, status };
|
|
51
|
+
}
|
|
52
|
+
// Update charge status regardless of event type.
|
|
53
|
+
await chargeStore.updateStatus(payload.invoiceId, status);
|
|
54
|
+
let result;
|
|
55
|
+
if (payload.type === "InvoiceSettled") {
|
|
56
|
+
// Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
|
|
57
|
+
// This is atomic — the referenceId is checked inside the ledger's serialized
|
|
58
|
+
// transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
|
|
59
|
+
const creditRef = `crypto:${payload.invoiceId}`;
|
|
60
|
+
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
61
|
+
result = {
|
|
62
|
+
handled: true,
|
|
63
|
+
status,
|
|
64
|
+
tenant: charge.tenantId,
|
|
65
|
+
creditedCents: 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Credit the original USD amount requested (not the crypto amount).
|
|
70
|
+
// For overpayments, we still credit the requested amount.
|
|
71
|
+
// charge.amountUsdCents is in USD cents (integer).
|
|
72
|
+
// Credit.fromCents() converts to nanodollars for the ledger.
|
|
73
|
+
const creditCents = charge.amountUsdCents;
|
|
74
|
+
await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
|
|
75
|
+
description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
|
|
76
|
+
referenceId: creditRef,
|
|
77
|
+
fundingSource: "crypto",
|
|
78
|
+
});
|
|
79
|
+
// Mark credited (advisory — primary idempotency is the ledger referenceId above).
|
|
80
|
+
await chargeStore.markCredited(payload.invoiceId);
|
|
81
|
+
// Reactivate suspended resources after credit purchase.
|
|
82
|
+
let reactivatedBots;
|
|
83
|
+
if (deps.onCreditsPurchased) {
|
|
84
|
+
reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
|
|
85
|
+
if (reactivatedBots.length === 0)
|
|
86
|
+
reactivatedBots = undefined;
|
|
87
|
+
}
|
|
88
|
+
result = {
|
|
89
|
+
handled: true,
|
|
90
|
+
status,
|
|
91
|
+
tenant: charge.tenantId,
|
|
92
|
+
creditedCents: creditCents,
|
|
93
|
+
reactivatedBots,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// New, Processing, Expired, Invalid — just track status.
|
|
99
|
+
result = {
|
|
100
|
+
handled: true,
|
|
101
|
+
status,
|
|
102
|
+
tenant: charge.tenantId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { DrizzleLedger } from "../../credits/ledger.js";
|
|
4
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
+
import { DrizzleWebhookSeenRepository } from "../drizzle-webhook-seen-repository.js";
|
|
6
|
+
import { noOpReplayGuard } from "../webhook-seen-repository.js";
|
|
7
|
+
import { CryptoChargeRepository } from "./charge-store.js";
|
|
8
|
+
import { mapBtcPayEventToStatus } from "./types.js";
|
|
9
|
+
import { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
10
|
+
function makePayload(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
deliveryId: "del-001",
|
|
13
|
+
webhookId: "whk-001",
|
|
14
|
+
originalDeliveryId: "del-001",
|
|
15
|
+
isRedelivery: false,
|
|
16
|
+
type: "InvoiceSettled",
|
|
17
|
+
timestamp: Date.now(),
|
|
18
|
+
storeId: "store-test",
|
|
19
|
+
invoiceId: "inv-test-001",
|
|
20
|
+
metadata: { orderId: "order-001" },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
let pool;
|
|
25
|
+
let db;
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
({ db, pool } = await createTestDb());
|
|
28
|
+
});
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await pool.close();
|
|
31
|
+
});
|
|
32
|
+
describe("handleCryptoWebhook", () => {
|
|
33
|
+
let chargeStore;
|
|
34
|
+
let creditLedger;
|
|
35
|
+
let deps;
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await truncateAllTables(pool);
|
|
38
|
+
chargeStore = new CryptoChargeRepository(db);
|
|
39
|
+
creditLedger = new DrizzleLedger(db);
|
|
40
|
+
await creditLedger.seedSystemAccounts();
|
|
41
|
+
deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
|
|
42
|
+
// Create a default test charge
|
|
43
|
+
await chargeStore.create("inv-test-001", "tenant-a", 2500);
|
|
44
|
+
});
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// InvoiceSettled — should credit ledger
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
describe("InvoiceSettled", () => {
|
|
49
|
+
it("credits the ledger with the requested USD amount", async () => {
|
|
50
|
+
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
51
|
+
expect(result.handled).toBe(true);
|
|
52
|
+
expect(result.status).toBe("Settled");
|
|
53
|
+
expect(result.tenant).toBe("tenant-a");
|
|
54
|
+
expect(result.creditedCents).toBe(2500);
|
|
55
|
+
const balance = await creditLedger.balance("tenant-a");
|
|
56
|
+
expect(balance.toCents()).toBe(2500);
|
|
57
|
+
});
|
|
58
|
+
it("uses crypto: prefix on reference ID in credit transaction", async () => {
|
|
59
|
+
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
60
|
+
const history = await creditLedger.history("tenant-a");
|
|
61
|
+
expect(history).toHaveLength(1);
|
|
62
|
+
expect(history[0].referenceId).toBe("crypto:inv-test-001");
|
|
63
|
+
expect(history[0].entryType).toBe("purchase");
|
|
64
|
+
});
|
|
65
|
+
it("records fundingSource as crypto", async () => {
|
|
66
|
+
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
67
|
+
const history = await creditLedger.history("tenant-a");
|
|
68
|
+
expect(history[0].metadata?.fundingSource).toBe("crypto");
|
|
69
|
+
});
|
|
70
|
+
it("marks the charge as credited after Settled", async () => {
|
|
71
|
+
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
72
|
+
expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
it("is idempotent — duplicate InvoiceSettled does not double-credit", async () => {
|
|
75
|
+
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
76
|
+
const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
77
|
+
expect(result2.handled).toBe(true);
|
|
78
|
+
expect(result2.creditedCents).toBe(0);
|
|
79
|
+
const balance = await creditLedger.balance("tenant-a");
|
|
80
|
+
expect(balance.toCents()).toBe(2500); // Only credited once
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Statuses that should NOT credit the ledger
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
describe("InvoiceProcessing", () => {
|
|
87
|
+
it("does NOT credit the ledger", async () => {
|
|
88
|
+
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
|
|
89
|
+
expect(result.handled).toBe(true);
|
|
90
|
+
expect(result.tenant).toBe("tenant-a");
|
|
91
|
+
expect(result.creditedCents).toBeUndefined();
|
|
92
|
+
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("InvoiceCreated", () => {
|
|
96
|
+
it("does NOT credit the ledger", async () => {
|
|
97
|
+
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
|
|
98
|
+
expect(result.handled).toBe(true);
|
|
99
|
+
expect(result.creditedCents).toBeUndefined();
|
|
100
|
+
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("InvoiceExpired", () => {
|
|
104
|
+
it("does NOT credit the ledger", async () => {
|
|
105
|
+
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
|
|
106
|
+
expect(result.handled).toBe(true);
|
|
107
|
+
expect(result.creditedCents).toBeUndefined();
|
|
108
|
+
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("InvoiceInvalid", () => {
|
|
112
|
+
it("does NOT credit the ledger", async () => {
|
|
113
|
+
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
|
|
114
|
+
expect(result.handled).toBe(true);
|
|
115
|
+
expect(result.creditedCents).toBeUndefined();
|
|
116
|
+
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Unknown invoice ID
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
describe("unknown invoiceId", () => {
|
|
123
|
+
it("returns handled:false when charge not found", async () => {
|
|
124
|
+
const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
|
|
125
|
+
expect(result.handled).toBe(false);
|
|
126
|
+
expect(result.tenant).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Charge store updates
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
describe("charge store updates", () => {
|
|
133
|
+
it("updates charge status on every webhook call", async () => {
|
|
134
|
+
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
|
|
135
|
+
const charge = await chargeStore.getByReferenceId("inv-test-001");
|
|
136
|
+
expect(charge?.status).toBe("Processing");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Multiple tenants
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
describe("different invoices", () => {
|
|
143
|
+
it("processes multiple invoices independently", async () => {
|
|
144
|
+
await chargeStore.create("inv-b-001", "tenant-b", 5000);
|
|
145
|
+
await chargeStore.create("inv-c-001", "tenant-c", 1500);
|
|
146
|
+
await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
|
|
147
|
+
await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
|
|
148
|
+
expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
|
|
149
|
+
expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Replay guard
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
describe("replay guard", () => {
|
|
156
|
+
it("blocks duplicate invoiceId + event type combos", async () => {
|
|
157
|
+
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
158
|
+
const depsWithGuard = { ...deps, replayGuard };
|
|
159
|
+
const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
160
|
+
expect(first.handled).toBe(true);
|
|
161
|
+
expect(first.creditedCents).toBe(2500);
|
|
162
|
+
expect(first.duplicate).toBeUndefined();
|
|
163
|
+
const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
164
|
+
expect(second.handled).toBe(true);
|
|
165
|
+
expect(second.duplicate).toBe(true);
|
|
166
|
+
expect(second.creditedCents).toBeUndefined();
|
|
167
|
+
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
|
|
168
|
+
});
|
|
169
|
+
it("same invoice with different event type is not blocked", async () => {
|
|
170
|
+
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
171
|
+
const depsWithGuard = { ...deps, replayGuard };
|
|
172
|
+
await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
|
|
173
|
+
const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
174
|
+
expect(result.duplicate).toBeUndefined();
|
|
175
|
+
expect(result.creditedCents).toBe(2500);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Unknown event type
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
describe("unknown event type", () => {
|
|
182
|
+
it("throws on unrecognized BTCPay event type", async () => {
|
|
183
|
+
await expect(handleCryptoWebhook(deps, makePayload({ type: "SomeUnknownEvent" }))).rejects.toThrow("Unknown BTCPay event type: SomeUnknownEvent");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Resource reactivation
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
describe("resource reactivation via onCreditsPurchased", () => {
|
|
190
|
+
it("calls onCreditsPurchased on Settled and includes reactivatedBots", async () => {
|
|
191
|
+
const mockOnCreditsPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
|
|
192
|
+
const depsWithCallback = {
|
|
193
|
+
...deps,
|
|
194
|
+
onCreditsPurchased: mockOnCreditsPurchased,
|
|
195
|
+
};
|
|
196
|
+
const result = await handleCryptoWebhook(depsWithCallback, makePayload({ type: "InvoiceSettled" }));
|
|
197
|
+
expect(mockOnCreditsPurchased).toHaveBeenCalledWith("tenant-a", creditLedger);
|
|
198
|
+
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
199
|
+
});
|
|
200
|
+
it("does not include reactivatedBots when no resources reactivated", async () => {
|
|
201
|
+
const mockOnCreditsPurchased = vi.fn().mockResolvedValue([]);
|
|
202
|
+
const depsWithCallback = {
|
|
203
|
+
...deps,
|
|
204
|
+
onCreditsPurchased: mockOnCreditsPurchased,
|
|
205
|
+
};
|
|
206
|
+
const result = await handleCryptoWebhook(depsWithCallback, makePayload({ type: "InvoiceSettled" }));
|
|
207
|
+
expect(result.reactivatedBots).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Webhook signature verification
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
describe("verifyCryptoWebhookSignature", () => {
|
|
215
|
+
const secret = "test-webhook-secret";
|
|
216
|
+
const body = '{"type":"InvoiceSettled","invoiceId":"inv-001"}';
|
|
217
|
+
it("returns true for valid signature", () => {
|
|
218
|
+
const sig = `sha256=${crypto.createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
219
|
+
expect(verifyCryptoWebhookSignature(body, sig, secret)).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
it("returns false for invalid signature", () => {
|
|
222
|
+
expect(verifyCryptoWebhookSignature(body, "sha256=badhex", secret)).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
it("returns false for wrong secret", () => {
|
|
225
|
+
const sig = `sha256=${crypto.createHmac("sha256", "wrong-secret").update(body).digest("hex")}`;
|
|
226
|
+
expect(verifyCryptoWebhookSignature(body, sig, secret)).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
it("returns false for tampered body", () => {
|
|
229
|
+
const sig = `sha256=${crypto.createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
230
|
+
expect(verifyCryptoWebhookSignature(`${body}tampered`, sig, secret)).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Replay guard unit tests
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// mapBtcPayEventToStatus
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
describe("mapBtcPayEventToStatus", () => {
|
|
240
|
+
it("maps known event types to CryptoPaymentState", () => {
|
|
241
|
+
expect(mapBtcPayEventToStatus("InvoiceCreated")).toBe("New");
|
|
242
|
+
expect(mapBtcPayEventToStatus("InvoiceReceivedPayment")).toBe("Processing");
|
|
243
|
+
expect(mapBtcPayEventToStatus("InvoiceProcessing")).toBe("Processing");
|
|
244
|
+
expect(mapBtcPayEventToStatus("InvoiceSettled")).toBe("Settled");
|
|
245
|
+
expect(mapBtcPayEventToStatus("InvoicePaymentSettled")).toBe("Settled");
|
|
246
|
+
expect(mapBtcPayEventToStatus("InvoiceExpired")).toBe("Expired");
|
|
247
|
+
expect(mapBtcPayEventToStatus("InvoiceInvalid")).toBe("Invalid");
|
|
248
|
+
});
|
|
249
|
+
it("throws on unknown event type", () => {
|
|
250
|
+
expect(() => mapBtcPayEventToStatus("SomethingElse")).toThrow("Unknown BTCPay event type: SomethingElse");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe("DrizzleWebhookSeenRepository (crypto replay guard)", () => {
|
|
254
|
+
beforeEach(async () => {
|
|
255
|
+
await truncateAllTables(pool);
|
|
256
|
+
});
|
|
257
|
+
it("reports unseen keys as not duplicate", async () => {
|
|
258
|
+
const guard = new DrizzleWebhookSeenRepository(db);
|
|
259
|
+
expect(await guard.isDuplicate("inv-001:InvoiceSettled", "crypto")).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
it("reports seen keys as duplicate", async () => {
|
|
262
|
+
const guard = new DrizzleWebhookSeenRepository(db);
|
|
263
|
+
await guard.markSeen("inv-001:InvoiceSettled", "crypto");
|
|
264
|
+
expect(await guard.isDuplicate("inv-001:InvoiceSettled", "crypto")).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
package/dist/billing/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
export * from "./crypto/index.js";
|
|
1
2
|
export { DrizzleWebhookSeenRepository } from "./drizzle-webhook-seen-repository.js";
|
|
2
3
|
export type { ChargeOpts, ChargeResult, CheckoutOpts, CheckoutSession, Invoice, IPaymentProcessor, PortalOpts, SavedPaymentMethod, SetupResult, WebhookResult, } from "./payment-processor.js";
|
|
3
4
|
export { PaymentMethodOwnershipError } from "./payment-processor.js";
|
|
4
|
-
export * from "./payram/index.js";
|
|
5
5
|
export * from "./stripe/index.js";
|
|
6
6
|
export type { IWebhookSeenRepository } from "./webhook-seen-repository.js";
|
|
7
7
|
export { noOpReplayGuard } from "./webhook-seen-repository.js";
|
package/dist/billing/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
// Crypto (BTCPay Server)
|
|
2
|
+
export * from "./crypto/index.js";
|
|
1
3
|
export { DrizzleWebhookSeenRepository } from "./drizzle-webhook-seen-repository.js";
|
|
2
4
|
export { PaymentMethodOwnershipError } from "./payment-processor.js";
|
|
3
|
-
// PayRam
|
|
4
|
-
export * from "./payram/index.js";
|
|
5
5
|
// Stripe
|
|
6
6
|
export * from "./stripe/index.js";
|
|
7
7
|
export { noOpReplayGuard } from "./webhook-seen-repository.js";
|
|
@@ -6,7 +6,7 @@ export declare class PaymentMethodOwnershipError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
/** A saved payment method on file for a tenant (processor-agnostic). */
|
|
8
8
|
export interface SavedPaymentMethod {
|
|
9
|
-
/** Processor-specific payment method ID (e.g. Stripe pm_xxx,
|
|
9
|
+
/** Processor-specific payment method ID (e.g. Stripe pm_xxx, BTCPay wallet address). */
|
|
10
10
|
id: string;
|
|
11
11
|
/** Human-readable label (e.g. "Visa ending 4242", "ETH wallet"). */
|
|
12
12
|
label: string;
|
|
@@ -77,12 +77,12 @@ export interface WebhookResult {
|
|
|
77
77
|
/**
|
|
78
78
|
* Processor-agnostic payment interface.
|
|
79
79
|
*
|
|
80
|
-
* Each payment processor (Stripe,
|
|
80
|
+
* Each payment processor (Stripe, BTCPay, future processors) implements
|
|
81
81
|
* this interface. The platform layer programs against IPaymentProcessor
|
|
82
82
|
* and never imports processor-specific types.
|
|
83
83
|
*/
|
|
84
84
|
export interface IPaymentProcessor {
|
|
85
|
-
/** Human-readable processor name (e.g. "stripe", "
|
|
85
|
+
/** Human-readable processor name (e.g. "stripe", "crypto"). */
|
|
86
86
|
readonly name: string;
|
|
87
87
|
/** Create a checkout session for a one-time credit purchase. */
|
|
88
88
|
createCheckoutSession(opts: CheckoutOpts): Promise<CheckoutSession>;
|