@ar-agents/mercadopago 0.15.2 → 0.16.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/AGENTS.md +30 -30
- package/CHANGELOG.md +43 -1
- package/MIGRATION.md +8 -8
- package/README.md +33 -32
- package/README.skeleton.md +21 -21
- package/cookbook/10-cross-package-billing.ts +172 -0
- package/cookbook/11-dunning-sequence.ts +305 -0
- package/cookbook/12-reconciliation-pipeline.ts +277 -0
- package/cookbook/README.md +3 -0
- package/dist/index.d.cts +4 -1084
- package/dist/index.d.ts +4 -1084
- package/dist/testing.cjs +281 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +188 -0
- package/dist/testing.d.ts +188 -0
- package/dist/testing.js +270 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-BaOjfcOt.d.cts +1085 -0
- package/dist/types-BaOjfcOt.d.ts +1085 -0
- package/package.json +30 -16
- package/tools.manifest.json +1 -1
package/README.skeleton.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
README skeleton
|
|
2
|
+
README skeleton: Vercel-official quality.
|
|
3
3
|
Drop this in as packages/mercadopago/README.md after a final once-over.
|
|
4
4
|
The structure follows the patterns Vercel themselves use on
|
|
5
5
|
https://github.com/vercel/ai-sdk and https://github.com/vercel/next.js
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
Hard rules:
|
|
9
9
|
- Lead with what it is and a 3-line install + first call.
|
|
10
|
-
- Real numbers (latency, bundle size) only
|
|
10
|
+
- Real numbers (latency, bundle size) only: no "fast", "blazing", "robust".
|
|
11
11
|
- Code blocks runnable as-pasted (no `// ...` placeholders that hide work).
|
|
12
12
|
- One-screen scrolling for the top: header → install → first call → core API.
|
|
13
13
|
- Everything below is reference, not pitch.
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
Mercado Pago Agent Toolkit. Built on Vercel. 89 typed tools across the agent-relevant Mercado Pago API surface (Subscriptions, Payments, Checkout Pro, Marketplace OAuth, Order Management, Customers, Cards, Cuotas, QR, 3DS, Point devices, Stores+POS, Account/Balance/Settlements, Webhooks, Disputes, Lookups, Bank Accounts) for the [Vercel AI SDK](https://ai-sdk.dev/) 6 `Experimental_Agent`. Edge-Runtime-safe.
|
|
25
25
|
|
|
26
|
-
> **Reading this as an agent?** Skip to [AGENTS.md](./AGENTS.md)
|
|
26
|
+
> **Reading this as an agent?** Skip to [AGENTS.md](./AGENTS.md): decision tree, result schemas to memorize, error patterns, latency table.
|
|
27
27
|
|
|
28
28
|
## Quick start
|
|
29
29
|
|
|
@@ -57,16 +57,16 @@ That's it. The agent picks `create_subscription`, returns an `init_point_url` yo
|
|
|
57
57
|
|
|
58
58
|
| | |
|
|
59
59
|
| --- | --- |
|
|
60
|
-
| **Tools** | 30
|
|
60
|
+
| **Tools** | 30: Subscriptions, Payments, Refunds, Checkout Pro, Cuotas, QR in-store, Saved cards, Marketplace OAuth, Order Management, Point devices, 3DS, Webhooks. [Full list](./AGENTS.md#tool-selection). |
|
|
61
61
|
| **Bundle size** | 41 KB ESM brotli'd ([bundlephobia](https://bundlephobia.com/package/@ar-agents/mercadopago)). Tree-shakable subpath exports for `/vercel-kv` + `/otel`. |
|
|
62
|
-
| **Runtime** | Vercel Edge, Node 18+, Cloudflare Workers, Deno
|
|
62
|
+
| **Runtime** | Vercel Edge, Node 18+, Cloudflare Workers, Deno: Web Crypto under the hood. |
|
|
63
63
|
| **Tests** | 290 unit + property + failure-injection + benchmark. `pnpm test`, `pnpm bench`. |
|
|
64
64
|
| **TypeScript** | Strict mode, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`. publint + arethetypeswrong all 🟢. |
|
|
65
65
|
| **AR-specific knowledge** | Cuotas with regulatory text (RG 5286/2023), AR issuer promo catalog, Subscription replay-protection, MLA-verified, ARS default. |
|
|
66
66
|
|
|
67
67
|
## Server-only
|
|
68
68
|
|
|
69
|
-
This package **MUST** run on the server. The constructor throws if instantiated in a browser context
|
|
69
|
+
This package **MUST** run on the server. The constructor throws if instantiated in a browser context: the access token would be exposed in the JavaScript bundle. Use Server Components, Route Handlers, or Server Actions.
|
|
70
70
|
|
|
71
71
|
```ts
|
|
72
72
|
// ❌ Never. Throws at runtime AND would leak the token if it didn't.
|
|
@@ -89,14 +89,14 @@ Server-side MP API client. Edge-Runtime safe.
|
|
|
89
89
|
|
|
90
90
|
| Option | Default | Description |
|
|
91
91
|
| --- | --- | --- |
|
|
92
|
-
| `accessToken` (required) |
|
|
92
|
+
| `accessToken` (required) | no | TEST- prefix for sandbox, APP_USR- for production. |
|
|
93
93
|
| `baseUrl` | `https://api.mercadopago.com` | Override for tests / regional hosts. |
|
|
94
94
|
| `fetch` | `globalThis.fetch` | Custom fetch (e.g., MSW for tests). |
|
|
95
95
|
| `requestTimeoutMs` | `30_000` | Per-request timeout. |
|
|
96
96
|
| `maxRetries` | `1` | 5xx + network retries. 4xx never retried. |
|
|
97
|
-
| `circuitBreaker` |
|
|
98
|
-
| `traceContext` |
|
|
99
|
-
| `onCall` |
|
|
97
|
+
| `circuitBreaker` | no | `new CircuitBreaker({ ... })` to fail fast on cascading failures. |
|
|
98
|
+
| `traceContext` | no | OpenTelemetry context propagator (W3C trace headers). |
|
|
99
|
+
| `onCall` | no | Observability hook fired after every request. |
|
|
100
100
|
|
|
101
101
|
### `mercadoPagoTools(client, options)`
|
|
102
102
|
|
|
@@ -104,7 +104,7 @@ Returns the agent tool set wired to the given client.
|
|
|
104
104
|
|
|
105
105
|
| Option | Required | Description |
|
|
106
106
|
| --- | --- | --- |
|
|
107
|
-
| `state` | yes | `SubscriptionStateAdapter
|
|
107
|
+
| `state` | yes | `SubscriptionStateAdapter`: `InMemoryStateAdapter`, `VercelKVSubscriptionStateAdapter`, or your own. |
|
|
108
108
|
| `backUrl` | yes | HTTPS URL where MP redirects buyers after first payment. localhost rejected. |
|
|
109
109
|
| `notificationUrl` | no | Webhook URL for new payments / status changes. |
|
|
110
110
|
| `oauth` | no | `{ clientId, clientSecret, redirectUri, tokenStore }` for marketplace OAuth flows. |
|
|
@@ -123,7 +123,7 @@ Returns the agent tool set wired to the given client.
|
|
|
123
123
|
|
|
124
124
|
### Idempotency by default
|
|
125
125
|
|
|
126
|
-
Every `POST` request gets an auto-generated UUID idempotency key
|
|
126
|
+
Every `POST` request gets an auto-generated UUID idempotency key: your app survives network blips without double-charging. For LLM-driven retries, `create_payment`, `create_subscription`, `create_payment_preference`, and `refund_payment` use a **deterministic** key derived from the inputs, so a tool retried with the same inputs returns the existing resource instead of creating a duplicate.
|
|
127
127
|
|
|
128
128
|
### Webhook verification
|
|
129
129
|
|
|
@@ -164,22 +164,22 @@ const limiter = new VercelKVRateLimiter({
|
|
|
164
164
|
|
|
165
165
|
### Cookbook
|
|
166
166
|
|
|
167
|
-
9 recipes in [`./cookbook`](./cookbook/)
|
|
167
|
+
9 recipes in [`./cookbook`](./cookbook/): Checkout Pro, SaaS subscription, webhook handler, marketplace split, QR in-store, 3DS challenge, manual capture, recovery patterns, full OpenTelemetry wiring.
|
|
168
168
|
|
|
169
169
|
## Comparison
|
|
170
170
|
|
|
171
171
|
| | `@ar-agents/mercadopago` | `mercadopago` (official SDK) | Hand-rolled |
|
|
172
172
|
| --- | --- | --- | --- |
|
|
173
|
-
| Tools as Vercel AI SDK 6 schemas | ✓ |
|
|
174
|
-
| AR-specific (cuotas, AR issuer promos, AR phone, MLA-verified) | ✓ |
|
|
175
|
-
| `AGENTS.md` per package (LLM-readable) | ✓ |
|
|
176
|
-
| Idempotency-by-default for state mutations | ✓ |
|
|
173
|
+
| Tools as Vercel AI SDK 6 schemas | ✓ | no | build it |
|
|
174
|
+
| AR-specific (cuotas, AR issuer promos, AR phone, MLA-verified) | ✓ | no | weeks |
|
|
175
|
+
| `AGENTS.md` per package (LLM-readable) | ✓ | no |: |
|
|
176
|
+
| Idempotency-by-default for state mutations | ✓ | no | build it |
|
|
177
177
|
| Webhook signature verify + 5-min replay window | ✓ | client only | build it |
|
|
178
178
|
| Edge Runtime support | ✓ | Node-only | build it |
|
|
179
|
-
| Vercel KV adapters via subpath | ✓ |
|
|
180
|
-
| OpenTelemetry instrumentation + recipe | ✓ |
|
|
181
|
-
| Circuit breaker + deadline propagation | ✓ |
|
|
182
|
-
| Tool middleware (compose audit/rate/metrics) | ✓ |
|
|
179
|
+
| Vercel KV adapters via subpath | ✓ | no |: |
|
|
180
|
+
| OpenTelemetry instrumentation + recipe | ✓ | no | build it |
|
|
181
|
+
| Circuit breaker + deadline propagation | ✓ | no | build it |
|
|
182
|
+
| Tool middleware (compose audit/rate/metrics) | ✓ | no |: |
|
|
183
183
|
| Time to first cobro | 30 min | 1+ week | 6-8 weeks |
|
|
184
184
|
|
|
185
185
|
See [`MIGRATION.md`](./MIGRATION.md) for a side-by-side `mercadopago` → `@ar-agents/mercadopago` migration guide.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 10 — Cross-package billing assistant.
|
|
3
|
+
*
|
|
4
|
+
* The killer demo of the @ar-agents/* toolkit's composability. ONE agent loop,
|
|
5
|
+
* five packages working together to do what would normally be 200 lines of
|
|
6
|
+
* orchestration code:
|
|
7
|
+
*
|
|
8
|
+
* 1. @ar-agents/identity — validate the buyer's CUIT, look up
|
|
9
|
+
* AFIP padron (monotributo + IVA condition)
|
|
10
|
+
* 2. @ar-agents/identity-attest — gate large charges behind WhatsApp OTP
|
|
11
|
+
* 3. @ar-agents/mercadopago — run the actual subscription / payment
|
|
12
|
+
* 4. @ar-agents/facturacion — emit factura electrónica WSFE on success
|
|
13
|
+
* 5. @ar-agents/whatsapp — send confirmation + invoice link
|
|
14
|
+
*
|
|
15
|
+
* Real production pattern: invoice an Argentine SMB customer, fully driven
|
|
16
|
+
* by an LLM agent reading natural-language business prompts.
|
|
17
|
+
*
|
|
18
|
+
* Run with `pnpm tsx cookbook/10-cross-package-billing.ts` after wiring env:
|
|
19
|
+
* MP_ACCESS_TOKEN
|
|
20
|
+
* AFIP_CERT_PEM, AFIP_KEY_PEM, AFIP_CUIT
|
|
21
|
+
* WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID
|
|
22
|
+
* ATTESTATION_HMAC_SECRET
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Experimental_Agent as Agent, stepCountIs, type ToolSet } from "ai";
|
|
26
|
+
|
|
27
|
+
// 1. Mercado Pago — always present, the headline package.
|
|
28
|
+
import {
|
|
29
|
+
MercadoPagoClient,
|
|
30
|
+
mercadoPagoTools,
|
|
31
|
+
InMemoryStateAdapter,
|
|
32
|
+
} from "@ar-agents/mercadopago";
|
|
33
|
+
|
|
34
|
+
// 2-5. Sidecar packages. Imported up-front because every cross-package agent
|
|
35
|
+
// will eventually need them; tree-shaking handles unused ones.
|
|
36
|
+
import {
|
|
37
|
+
identityTools,
|
|
38
|
+
WsaaWscdcAfipPadronAdapter,
|
|
39
|
+
UnconfiguredAfipPadronAdapter,
|
|
40
|
+
type AfipPadronAdapter,
|
|
41
|
+
} from "@ar-agents/identity";
|
|
42
|
+
import {
|
|
43
|
+
AttestationClient,
|
|
44
|
+
identityAttestTools,
|
|
45
|
+
InMemoryAttestationStore,
|
|
46
|
+
} from "@ar-agents/identity-attest";
|
|
47
|
+
import {
|
|
48
|
+
WsfeClient,
|
|
49
|
+
facturacionTools,
|
|
50
|
+
} from "@ar-agents/facturacion";
|
|
51
|
+
import {
|
|
52
|
+
WhatsAppClient,
|
|
53
|
+
whatsappTools,
|
|
54
|
+
} from "@ar-agents/whatsapp";
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Build the cross-package tool surface
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export async function buildBillingAgent() {
|
|
61
|
+
const tools: ToolSet = {};
|
|
62
|
+
|
|
63
|
+
// ── Mercado Pago ──────────────────────────────────────────────────────────
|
|
64
|
+
const mp = new MercadoPagoClient({
|
|
65
|
+
accessToken: process.env.MP_ACCESS_TOKEN!,
|
|
66
|
+
});
|
|
67
|
+
Object.assign(
|
|
68
|
+
tools,
|
|
69
|
+
mercadoPagoTools(mp, {
|
|
70
|
+
state: new InMemoryStateAdapter(),
|
|
71
|
+
backUrl: process.env.NEXT_PUBLIC_BACK_URL ?? "https://example.com/done",
|
|
72
|
+
// HITL on irreversible ops. In production: push approval request to a
|
|
73
|
+
// dashboard / Slack / email and block on user UI. For the demo: auto-OK.
|
|
74
|
+
requireConfirmation: async (toolName, params) => {
|
|
75
|
+
console.log(`[HITL] ${toolName} called with`, params);
|
|
76
|
+
return true;
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// ── Identity (CUIT + AFIP/ARCA padron) ────────────────────────────────────
|
|
82
|
+
// Wire the real WSAA adapter only when the cert is present; otherwise the
|
|
83
|
+
// unconfigured adapter is registered so `validate_cuit` works (algorithm
|
|
84
|
+
// only) but `lookup_padron` returns "not configured" cleanly.
|
|
85
|
+
const afipAdapter: AfipPadronAdapter =
|
|
86
|
+
process.env.AFIP_CERT_PEM && process.env.AFIP_KEY_PEM
|
|
87
|
+
? new WsaaWscdcAfipPadronAdapter({
|
|
88
|
+
certPem: process.env.AFIP_CERT_PEM,
|
|
89
|
+
keyPem: process.env.AFIP_KEY_PEM,
|
|
90
|
+
cuitRepresentado: process.env.AFIP_CUIT!,
|
|
91
|
+
env: "prod",
|
|
92
|
+
})
|
|
93
|
+
: new UnconfiguredAfipPadronAdapter();
|
|
94
|
+
Object.assign(tools, identityTools({ afip: afipAdapter }));
|
|
95
|
+
|
|
96
|
+
// ── Identity-attest (WhatsApp OTP gate for >$50k) ─────────────────────────
|
|
97
|
+
if (process.env.ATTESTATION_HMAC_SECRET) {
|
|
98
|
+
const attestClient = new AttestationClient({
|
|
99
|
+
hmacSecret: process.env.ATTESTATION_HMAC_SECRET,
|
|
100
|
+
store: new InMemoryAttestationStore(),
|
|
101
|
+
});
|
|
102
|
+
Object.assign(tools, identityAttestTools(attestClient));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Facturación (factura electrónica WSFE) ────────────────────────────────
|
|
106
|
+
if (process.env.AFIP_CERT_PEM && process.env.AFIP_KEY_PEM) {
|
|
107
|
+
const wsfe = new WsfeClient({
|
|
108
|
+
certPem: process.env.AFIP_CERT_PEM,
|
|
109
|
+
keyPem: process.env.AFIP_KEY_PEM,
|
|
110
|
+
cuit: Number(process.env.AFIP_CUIT!),
|
|
111
|
+
env: "prod",
|
|
112
|
+
});
|
|
113
|
+
Object.assign(tools, facturacionTools({ wsfe }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── WhatsApp (confirmation + invoice link) ────────────────────────────────
|
|
117
|
+
if (process.env.WHATSAPP_ACCESS_TOKEN && process.env.WHATSAPP_PHONE_NUMBER_ID) {
|
|
118
|
+
const wa = new WhatsAppClient({
|
|
119
|
+
accessToken: process.env.WHATSAPP_ACCESS_TOKEN,
|
|
120
|
+
phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID,
|
|
121
|
+
});
|
|
122
|
+
Object.assign(tools, whatsappTools({ client: wa }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return new Agent({
|
|
126
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
127
|
+
instructions:
|
|
128
|
+
"Sos un asistente de billing para SaaS argentinas. Antes de cobrar, " +
|
|
129
|
+
"validás el CUIT con `validate_cuit` y consultás el padrón AFIP con " +
|
|
130
|
+
"`lookup_padron` para conocer la condición IVA del receptor. Para " +
|
|
131
|
+
"cargos sobre $50.000 ARS, gatillás verificación WhatsApp OTP via " +
|
|
132
|
+
"`request_attestation`. Después del cobro emitís factura electrónica " +
|
|
133
|
+
"con `crear_factura` (B si es Consumidor Final, A si es Responsable " +
|
|
134
|
+
"Inscripto, C si tu emisor es monotributo). Mandás link del " +
|
|
135
|
+
"comprobante por WhatsApp con `send_text`. Respondé en castellano " +
|
|
136
|
+
"rioplatense, breve, sin emojis.",
|
|
137
|
+
tools,
|
|
138
|
+
stopWhen: stepCountIs(15), // higher than usual — multi-package flows take steps
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
// Example invocation
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function main() {
|
|
147
|
+
const agent = await buildBillingAgent();
|
|
148
|
+
|
|
149
|
+
// What the agent should do behind this prompt:
|
|
150
|
+
// 1. validate_cuit("20-12345678-9") → ok
|
|
151
|
+
// 2. lookup_padron("20-12345678-9") → returns "Acme SRL, monotributo Cat A, Responsable Inscripto"
|
|
152
|
+
// 3. amount > $50k → request_attestation(method="whatsapp_otp", target="+5491155555555")
|
|
153
|
+
// 4. (after OTP confirmed) create_subscription({ amount: 75000, frequency: "monthly", payerEmail })
|
|
154
|
+
// 5. (async, after first payment webhook) crear_factura(B, monto, items)
|
|
155
|
+
// 6. send_text(phone, "Suscripción activa. Factura: $url")
|
|
156
|
+
const result = await agent.generate({
|
|
157
|
+
prompt:
|
|
158
|
+
"Cobrale $75.000 mensual a Acme SRL (CUIT 20-12345678-9, " +
|
|
159
|
+
"email contacto@acme.example, WhatsApp +5491155555555) por el plan Pro. " +
|
|
160
|
+
"Como supera los $50k, gatillá la verificación primero. Después emití " +
|
|
161
|
+
"factura B y mandales el link por WhatsApp.",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
console.log(result.text);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (process.argv[1]?.endsWith("10-cross-package-billing.ts")) {
|
|
168
|
+
main().catch((err) => {
|
|
169
|
+
console.error(err);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe 11 — Dunning sequence: failed-payment recovery loop.
|
|
3
|
+
*
|
|
4
|
+
* Real production pattern. A subscription's recurring charge fails. You don't
|
|
5
|
+
* just give up — you run a multi-step recovery sequence that maximises revenue
|
|
6
|
+
* recovery and minimises customer churn.
|
|
7
|
+
*
|
|
8
|
+
* # The dunning sequence
|
|
9
|
+
*
|
|
10
|
+
* Day 0 Charge fails (most commonly: insufficient funds, card expired).
|
|
11
|
+
* → MP retries automatically (configurable on the subscription, default 3 attempts).
|
|
12
|
+
* Day 0 Webhook: `subscription_authorized_payment` with status=rejected.
|
|
13
|
+
* → Send "Hubo un problema con tu cobro" email + WhatsApp.
|
|
14
|
+
* → Include the buyer's `init_point_url` so they can retry the
|
|
15
|
+
* card on MP's UI without you collecting card data.
|
|
16
|
+
* Day 3 Still no successful retry.
|
|
17
|
+
* → Pause the subscription via `pause_subscription`.
|
|
18
|
+
* → Send a softer "Tu suscripción está pausada — ¿querés que
|
|
19
|
+
* actualicemos la tarjeta?" message.
|
|
20
|
+
* Day 7 No card swap.
|
|
21
|
+
* → Send retention offer: "Te damos un mes gratis si volvés".
|
|
22
|
+
* Day 14 No response to retention.
|
|
23
|
+
* → Cancel the subscription. Send "Cancelamos. ¿Te podemos ayudar
|
|
24
|
+
* con algo?" message with feedback link.
|
|
25
|
+
*
|
|
26
|
+
* # What this recipe shows
|
|
27
|
+
*
|
|
28
|
+
* - Webhook handler reading `subscription_authorized_payment` events.
|
|
29
|
+
* - State machine driven by elapsed time + buyer responses.
|
|
30
|
+
* - Composition with @ar-agents/whatsapp for the dunning message channel.
|
|
31
|
+
* - HITL gating on the cancellation step (retention managers might want
|
|
32
|
+
* to manually approve cancellations of high-value accounts).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
MercadoPagoClient,
|
|
37
|
+
parseWebhookEvent,
|
|
38
|
+
verifyWebhookSignature,
|
|
39
|
+
explainPaymentStatus,
|
|
40
|
+
type ParsedWebhookEvent,
|
|
41
|
+
type SubscriptionPayment,
|
|
42
|
+
} from "@ar-agents/mercadopago";
|
|
43
|
+
|
|
44
|
+
const mp = new MercadoPagoClient({
|
|
45
|
+
accessToken: process.env.MP_ACCESS_TOKEN!,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// State store
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
// In production: VercelKV / Redis / Postgres. Schema:
|
|
53
|
+
// key: dunning:<subscriptionId>
|
|
54
|
+
// value: { firstFailureAt, attemptsSent, status: "active" | "paused" | "cancelled" }
|
|
55
|
+
type DunningState = {
|
|
56
|
+
subscriptionId: string;
|
|
57
|
+
firstFailureAt: number;
|
|
58
|
+
attemptsSent: number;
|
|
59
|
+
status: "active" | "paused" | "cancelled";
|
|
60
|
+
buyerEmail: string;
|
|
61
|
+
buyerWhatsApp?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const dunningStore = new Map<string, DunningState>();
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Webhook handler — entry point for the dunning sequence
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export async function POST(req: Request) {
|
|
71
|
+
const url = new URL(req.url);
|
|
72
|
+
const rawBody = await req.text();
|
|
73
|
+
|
|
74
|
+
const ok = await verifyWebhookSignature({
|
|
75
|
+
requestId: req.headers.get("x-request-id"),
|
|
76
|
+
dataId: parseWebhookEvent(JSON.parse(rawBody), url.searchParams)?.dataId ?? "",
|
|
77
|
+
signatureHeader: req.headers.get("x-signature"),
|
|
78
|
+
secret: process.env.MP_WEBHOOK_SECRET!,
|
|
79
|
+
});
|
|
80
|
+
if (!ok) return new Response("invalid signature", { status: 401 });
|
|
81
|
+
|
|
82
|
+
const event = parseWebhookEvent(JSON.parse(rawBody), url.searchParams);
|
|
83
|
+
if (!event) return new Response("ok", { status: 200 });
|
|
84
|
+
|
|
85
|
+
// Two relevant topics: subscription_authorized_payment (recurring charge),
|
|
86
|
+
// and payment.updated (in case of one-shot charge associated to a sub).
|
|
87
|
+
if (event.topic === "subscription_authorized_payment") {
|
|
88
|
+
await handleRecurringChargeWebhook(event);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Response("ok", { status: 200 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The webhook payload includes a `data.id` for the SubscriptionPayment that
|
|
96
|
+
* fired. To find the parent preapproval we hit MP's auth payments endpoint
|
|
97
|
+
* directly — there's no single-record getter on the client (MP's API returns
|
|
98
|
+
* authorized_payments only via the search endpoint), so the recipe goes
|
|
99
|
+
* through the raw request helper.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchSubscriptionPaymentById(
|
|
102
|
+
authPaymentId: string,
|
|
103
|
+
): Promise<SubscriptionPayment | null> {
|
|
104
|
+
// The toolkit doesn't expose a single-record getter for SubscriptionPayment
|
|
105
|
+
// because MP doesn't ship one either. The closest stable path is the
|
|
106
|
+
// /authorized_payments search query. In your dunning state store you'll
|
|
107
|
+
// already know the preapproval_id, so this lookup is rarely needed —
|
|
108
|
+
// included here for completeness when the webhook is the only source.
|
|
109
|
+
try {
|
|
110
|
+
const url = `https://api.mercadopago.com/authorized_payments/${authPaymentId}`;
|
|
111
|
+
const res = await fetch(url, {
|
|
112
|
+
headers: {
|
|
113
|
+
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok) return null;
|
|
117
|
+
return (await res.json()) as SubscriptionPayment;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleRecurringChargeWebhook(event: ParsedWebhookEvent) {
|
|
124
|
+
const ap = await fetchSubscriptionPaymentById(event.dataId);
|
|
125
|
+
if (!ap || !ap.preapproval_id) return;
|
|
126
|
+
|
|
127
|
+
if (ap.status === "approved") {
|
|
128
|
+
// Reset dunning state — recurring charge recovered.
|
|
129
|
+
dunningStore.delete(ap.preapproval_id);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ap.status !== "rejected") return; // pending — wait for next event
|
|
134
|
+
|
|
135
|
+
// Charge was rejected. Engage the dunning sequence. explainPaymentStatus
|
|
136
|
+
// wants a full Payment shape; the relevant fields (status + status_detail)
|
|
137
|
+
// come from the SubscriptionPayment, so we widen the cast.
|
|
138
|
+
const explained = explainPaymentStatus({
|
|
139
|
+
id: String(ap.id),
|
|
140
|
+
status: ap.status,
|
|
141
|
+
status_detail: ap.reason ?? "",
|
|
142
|
+
transaction_amount: ap.transaction_amount ?? 0,
|
|
143
|
+
currency_id: ap.currency_id ?? "ARS",
|
|
144
|
+
} as unknown as Parameters<typeof explainPaymentStatus>[0]);
|
|
145
|
+
|
|
146
|
+
const sub = await mp.getPreapproval(ap.preapproval_id);
|
|
147
|
+
let state = dunningStore.get(ap.preapproval_id);
|
|
148
|
+
|
|
149
|
+
if (!state) {
|
|
150
|
+
state = {
|
|
151
|
+
subscriptionId: ap.preapproval_id,
|
|
152
|
+
firstFailureAt: Date.now(),
|
|
153
|
+
attemptsSent: 0,
|
|
154
|
+
status: "active",
|
|
155
|
+
buyerEmail: sub.payer_email ?? "",
|
|
156
|
+
};
|
|
157
|
+
dunningStore.set(ap.preapproval_id, state);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await runDunningStep(state, explained);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
// Dunning state machine
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async function runDunningStep(
|
|
168
|
+
state: DunningState,
|
|
169
|
+
explained: ReturnType<typeof explainPaymentStatus>,
|
|
170
|
+
) {
|
|
171
|
+
const elapsedDays = (Date.now() - state.firstFailureAt) / (24 * 60 * 60 * 1000);
|
|
172
|
+
|
|
173
|
+
if (elapsedDays < 3 && state.attemptsSent === 0) {
|
|
174
|
+
// Day 0: friendly heads-up. The buyer can retry the card via the
|
|
175
|
+
// subscription's init_point_url (MP UI handles re-auth).
|
|
176
|
+
await sendMessage(state.buyerEmail, "first-failure", {
|
|
177
|
+
reason: explained.summary,
|
|
178
|
+
retryUrl: await fetchInitPoint(state.subscriptionId),
|
|
179
|
+
});
|
|
180
|
+
state.attemptsSent = 1;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (elapsedDays >= 3 && elapsedDays < 7 && state.attemptsSent === 1) {
|
|
185
|
+
// Day 3: pause the subscription.
|
|
186
|
+
await mp.pausePreapproval(state.subscriptionId);
|
|
187
|
+
state.status = "paused";
|
|
188
|
+
await sendMessage(state.buyerEmail, "paused", {
|
|
189
|
+
retryUrl: await fetchInitPoint(state.subscriptionId),
|
|
190
|
+
});
|
|
191
|
+
state.attemptsSent = 2;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (elapsedDays >= 7 && elapsedDays < 14 && state.attemptsSent === 2) {
|
|
196
|
+
// Day 7: retention offer.
|
|
197
|
+
await sendMessage(state.buyerEmail, "retention-offer", {
|
|
198
|
+
offer: "1 mes gratis si volvés en los próximos 7 días",
|
|
199
|
+
retryUrl: await fetchInitPoint(state.subscriptionId),
|
|
200
|
+
});
|
|
201
|
+
state.attemptsSent = 3;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (elapsedDays >= 14 && state.attemptsSent === 3) {
|
|
206
|
+
// Day 14: cancel.
|
|
207
|
+
// HITL: in production, route this to a human approval queue first.
|
|
208
|
+
// For this recipe, we cancel immediately.
|
|
209
|
+
await mp.cancelPreapproval(state.subscriptionId);
|
|
210
|
+
state.status = "cancelled";
|
|
211
|
+
await sendMessage(state.buyerEmail, "cancelled", {});
|
|
212
|
+
state.attemptsSent = 4;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
// Side-effects (replace with your channel of choice)
|
|
219
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
async function fetchInitPoint(subscriptionId: string): Promise<string> {
|
|
222
|
+
const sub = await mp.getPreapproval(subscriptionId);
|
|
223
|
+
return sub.init_point;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function sendMessage(
|
|
227
|
+
email: string,
|
|
228
|
+
template: "first-failure" | "paused" | "retention-offer" | "cancelled",
|
|
229
|
+
data: Record<string, string>,
|
|
230
|
+
) {
|
|
231
|
+
// In production: compose an email via Resend / Postmark, AND send a
|
|
232
|
+
// WhatsApp via @ar-agents/whatsapp. Keeping this stub here so the recipe
|
|
233
|
+
// is copy-pasteable into any channel.
|
|
234
|
+
console.log(`[dunning] ${template} -> ${email}`, data);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Cron job — fallback when webhooks miss
|
|
239
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run on a daily Vercel Cron. Catches dunning states that didn't progress
|
|
243
|
+
* because the buyer never triggered a webhook (e.g. they ignored the email
|
|
244
|
+
* and didn't retry their card — no event fires until their NEXT scheduled
|
|
245
|
+
* recurring charge).
|
|
246
|
+
*
|
|
247
|
+
* Add to vercel.json:
|
|
248
|
+
*
|
|
249
|
+
* {
|
|
250
|
+
* "crons": [
|
|
251
|
+
* { "path": "/api/cron/dunning-tick", "schedule": "0 9 * * *" }
|
|
252
|
+
* ]
|
|
253
|
+
* }
|
|
254
|
+
*/
|
|
255
|
+
export async function dunningTick() {
|
|
256
|
+
for (const state of dunningStore.values()) {
|
|
257
|
+
if (state.status === "cancelled") continue;
|
|
258
|
+
await runDunningStep(
|
|
259
|
+
state,
|
|
260
|
+
explainPaymentStatus({
|
|
261
|
+
id: "tick",
|
|
262
|
+
status: "rejected",
|
|
263
|
+
status_detail: "cc_rejected_call_for_authorize",
|
|
264
|
+
transaction_amount: 0,
|
|
265
|
+
currency_id: "ARS",
|
|
266
|
+
} as unknown as Parameters<typeof explainPaymentStatus>[0]),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// Test harness — run with `pnpm tsx cookbook/11-dunning-sequence.ts`
|
|
273
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async function main() {
|
|
276
|
+
// Simulate a failure event on subscription "abc123".
|
|
277
|
+
const fakeState: DunningState = {
|
|
278
|
+
subscriptionId: "abc123",
|
|
279
|
+
firstFailureAt: Date.now() - 4 * 24 * 60 * 60 * 1000, // 4 days ago
|
|
280
|
+
attemptsSent: 1,
|
|
281
|
+
status: "active",
|
|
282
|
+
buyerEmail: "test@example.com",
|
|
283
|
+
};
|
|
284
|
+
dunningStore.set("abc123", fakeState);
|
|
285
|
+
|
|
286
|
+
await runDunningStep(
|
|
287
|
+
fakeState,
|
|
288
|
+
explainPaymentStatus({
|
|
289
|
+
id: "test",
|
|
290
|
+
status: "rejected",
|
|
291
|
+
status_detail: "cc_rejected_insufficient_amount",
|
|
292
|
+
transaction_amount: 1000,
|
|
293
|
+
currency_id: "ARS",
|
|
294
|
+
} as unknown as Parameters<typeof explainPaymentStatus>[0]),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
console.log("Dunning state after step:", dunningStore.get("abc123"));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (process.argv[1]?.endsWith("11-dunning-sequence.ts")) {
|
|
301
|
+
main().catch((err: unknown) => {
|
|
302
|
+
console.error(err);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
});
|
|
305
|
+
}
|