@fire-spark/payments-payphone-checkout 0.0.0-stage → 0.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/dist/index.d.ts +141 -0
- package/dist/index.js +384 -0
- package/package.json +56 -3
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { PaymentAdapter, PaymentContext, PaymentPolicy } from "@fire-spark/payments";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
/** Answer of the Spark Payphone operations (no token, no card data). */
|
|
4
|
+
type PayphoneOutcome = {
|
|
5
|
+
status: "PAID" | "FAILED" | "PENDING";
|
|
6
|
+
status_code: number | null;
|
|
7
|
+
transaction_id: number | null;
|
|
8
|
+
client_transaction_id: string | null;
|
|
9
|
+
authorization_code: string | null;
|
|
10
|
+
amount: number | null;
|
|
11
|
+
card_brand: string | null;
|
|
12
|
+
card_type: string | null;
|
|
13
|
+
last_digits: string | null;
|
|
14
|
+
deferred: boolean | null;
|
|
15
|
+
message: string | null;
|
|
16
|
+
message_code: number | null;
|
|
17
|
+
error_code: number | null;
|
|
18
|
+
};
|
|
19
|
+
/** `prepare` answer: the Payphone form URLs for this attempt. */
|
|
20
|
+
type PayphonePrepared = {
|
|
21
|
+
payment_id: string | number | null;
|
|
22
|
+
pay_with_card: string | null;
|
|
23
|
+
pay_with_payphone: string | null;
|
|
24
|
+
};
|
|
25
|
+
/** Query string Payphone sends to `responseUrl` after the form. */
|
|
26
|
+
type PayphoneReturn = {
|
|
27
|
+
id: number;
|
|
28
|
+
clientTransactionId: string;
|
|
29
|
+
};
|
|
30
|
+
type PayphoneRedirectInput = {
|
|
31
|
+
/** Payphone form to open in the browser (never in an iframe/WebView). */
|
|
32
|
+
url: string;
|
|
33
|
+
attemptReference: string;
|
|
34
|
+
responseUrl: string;
|
|
35
|
+
/** Aborted on timeout or cancel: close the tab, resolve `cancelled`. */
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* How the redirect ended. A popup or new tab resolves `returned` with the
|
|
40
|
+
* query of the `responseUrl` page (e.g. sent by `postMessage`); a full
|
|
41
|
+
* page navigation resolves `redirected` and the return page resumes with
|
|
42
|
+
* `takeReturn`.
|
|
43
|
+
*/
|
|
44
|
+
type PayphoneRedirectOutcome = ({
|
|
45
|
+
type: "returned";
|
|
46
|
+
} & PayphoneReturn) | {
|
|
47
|
+
type: "cancelled";
|
|
48
|
+
} | {
|
|
49
|
+
type: "redirected";
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Implemented once per web template. Payphone only accepts its form opened
|
|
53
|
+
* from the domain registered in Payphone Developer, in a browser tab.
|
|
54
|
+
*/
|
|
55
|
+
type PayphoneRedirectBridge = {
|
|
56
|
+
open(input: PayphoneRedirectInput): Promise<PayphoneRedirectOutcome>;
|
|
57
|
+
/**
|
|
58
|
+
* After a full page redirect: the return of `attemptReference` read from
|
|
59
|
+
* the `responseUrl` page (see `parsePayphoneReturn`), once; else `null`.
|
|
60
|
+
*/
|
|
61
|
+
takeReturn?(attemptReference: string): Promise<PayphoneReturn | null>;
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/card.d.ts
|
|
65
|
+
type PayphoneCardOptions = {
|
|
66
|
+
/** Opens the Payphone form in a browser tab and reports the return. */
|
|
67
|
+
bridge: PayphoneRedirectBridge;
|
|
68
|
+
/** https page of the registered domain that reads `id` and `clientTransactionId`. */
|
|
69
|
+
responseUrl: string;
|
|
70
|
+
cancellationUrl?: string;
|
|
71
|
+
/** `card` (default) or `payphone` (balance, inside Payphone's form). */
|
|
72
|
+
payWith?: "card" | "payphone";
|
|
73
|
+
language?: "es" | "en";
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* `id` and `clientTransactionId` of a Payphone return URL (the page at
|
|
77
|
+
* `responseUrl`); `null` if it is not one.
|
|
78
|
+
*/
|
|
79
|
+
declare const parsePayphoneReturn: (url: string) => PayphoneReturn | null;
|
|
80
|
+
/**
|
|
81
|
+
* Payphone card on the web: Botón de Pago por redirección. Spark prepares
|
|
82
|
+
* the form with the token (`prepare`), the template opens it in a browser
|
|
83
|
+
* tab (`bridge`), and Spark confirms the return (`confirm`) within the 5
|
|
84
|
+
* minutes Payphone allows before it reverses. A paid charge is reversed by
|
|
85
|
+
* Spark with the order cancel or refund, the same day only. Web only:
|
|
86
|
+
* Payphone forbids its form in apps, iframes and WebViews.
|
|
87
|
+
*/
|
|
88
|
+
declare const payphoneCardPayments: (options: PayphoneCardOptions) => PaymentAdapter;
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/constants.d.ts
|
|
91
|
+
declare const PAYPHONE_PROVIDER = "payphone";
|
|
92
|
+
/** API Sale: the customer has 5 minutes to approve in the Payphone app. */
|
|
93
|
+
declare const PAYPHONE_SALE_TTL_MS = 300000;
|
|
94
|
+
/**
|
|
95
|
+
* Card: the Payphone form lives 10 minutes and an unconfirmed charge is
|
|
96
|
+
* reversed after 5. One automatic attempt: a retry is a new charge.
|
|
97
|
+
*/
|
|
98
|
+
declare const PAYPHONE_CARD_POLICY: Partial<PaymentPolicy>;
|
|
99
|
+
/** Wallet push: 5 minutes to approve, status read every 3 seconds. */
|
|
100
|
+
declare const PAYPHONE_WALLET_POLICY: Partial<PaymentPolicy>;
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/errors.d.ts
|
|
103
|
+
/**
|
|
104
|
+
* Payphone `errorCode` values seen in the official docs; the full catalog
|
|
105
|
+
* is pending (ficha §13). Unknown codes fall back per method.
|
|
106
|
+
*/
|
|
107
|
+
declare const payphoneErrors: Readonly<Record<string, import("@fire-spark/payments").PaymentErrorEntry>> & {
|
|
108
|
+
"20": {
|
|
109
|
+
code: "AMBIGUOUS";
|
|
110
|
+
source: string;
|
|
111
|
+
};
|
|
112
|
+
"25": {
|
|
113
|
+
code: "AMBIGUOUS";
|
|
114
|
+
source: string;
|
|
115
|
+
};
|
|
116
|
+
"2029": {
|
|
117
|
+
code: "INVALID_REQUEST";
|
|
118
|
+
source: string;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/wallet.d.ts
|
|
123
|
+
type PayphoneWalletOptions = {
|
|
124
|
+
/**
|
|
125
|
+
* Phone registered in Payphone. Default: the order customer phone, else
|
|
126
|
+
* asked with `ui.input`.
|
|
127
|
+
*/
|
|
128
|
+
phone?: (ctx: PaymentContext) => Promise<string | null>;
|
|
129
|
+
phoneLabel?: string;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Payphone app push (API Sale) for kiosks and mobile apps: Spark sends
|
|
133
|
+
* the charge to the customer's Payphone app, the customer approves it
|
|
134
|
+
* there (balance or a card saved in the app) within 5 minutes, and the
|
|
135
|
+
* adapter reads the status. A push nobody approves is withdrawn. A paid
|
|
136
|
+
* charge is reversed by Spark with the order cancel or refund, the same
|
|
137
|
+
* day only.
|
|
138
|
+
*/
|
|
139
|
+
declare const payphoneWalletPayments: (options?: PayphoneWalletOptions) => PaymentAdapter;
|
|
140
|
+
//#endregion
|
|
141
|
+
export { PAYPHONE_CARD_POLICY, PAYPHONE_PROVIDER, PAYPHONE_SALE_TTL_MS, PAYPHONE_WALLET_POLICY, PayphoneCardOptions, type PayphoneOutcome, type PayphonePrepared, type PayphoneRedirectBridge, type PayphoneRedirectInput, type PayphoneRedirectOutcome, type PayphoneReturn, PayphoneWalletOptions, parsePayphoneReturn, payphoneCardPayments, payphoneErrors, payphoneWalletPayments };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { PaymentError, createPaymentError, definePaymentErrors, toPaymentError } from "@fire-spark/payments";
|
|
2
|
+
//#region src/constants.ts
|
|
3
|
+
const PAYPHONE_PROVIDER = "payphone";
|
|
4
|
+
/** API Sale: the customer has 5 minutes to approve in the Payphone app. */
|
|
5
|
+
const PAYPHONE_SALE_TTL_MS = 3e5;
|
|
6
|
+
/**
|
|
7
|
+
* Card: the Payphone form lives 10 minutes and an unconfirmed charge is
|
|
8
|
+
* reversed after 5. One automatic attempt: a retry is a new charge.
|
|
9
|
+
*/
|
|
10
|
+
const PAYPHONE_CARD_POLICY = {
|
|
11
|
+
timeout_ms: 9e5,
|
|
12
|
+
max_attempts: 1
|
|
13
|
+
};
|
|
14
|
+
/** Wallet push: 5 minutes to approve, status read every 3 seconds. */
|
|
15
|
+
const PAYPHONE_WALLET_POLICY = {
|
|
16
|
+
timeout_ms: 36e4,
|
|
17
|
+
poll_interval_ms: 3e3,
|
|
18
|
+
max_attempts: 1
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/errors.ts
|
|
22
|
+
const source = "docs/payments/payphone/checkout.md#11-errores";
|
|
23
|
+
/**
|
|
24
|
+
* Payphone `errorCode` values seen in the official docs; the full catalog
|
|
25
|
+
* is pending (ficha §13). Unknown codes fall back per method.
|
|
26
|
+
*/
|
|
27
|
+
const payphoneErrors = definePaymentErrors({
|
|
28
|
+
"20": {
|
|
29
|
+
code: "AMBIGUOUS",
|
|
30
|
+
source
|
|
31
|
+
},
|
|
32
|
+
"25": {
|
|
33
|
+
code: "AMBIGUOUS",
|
|
34
|
+
source
|
|
35
|
+
},
|
|
36
|
+
"2029": {
|
|
37
|
+
code: "INVALID_REQUEST",
|
|
38
|
+
source
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/result.ts
|
|
43
|
+
const CARD_TYPES = {
|
|
44
|
+
credit: "CREDIT",
|
|
45
|
+
debit: "DEBIT",
|
|
46
|
+
prepaid: "PREPAID"
|
|
47
|
+
};
|
|
48
|
+
const last4Of = (digits) => {
|
|
49
|
+
const clean = (digits ?? "").replace(/\D/g, "");
|
|
50
|
+
return clean.length >= 4 ? clean.slice(-4) : null;
|
|
51
|
+
};
|
|
52
|
+
/** Ids and card data of a Payphone outcome; nothing sensitive. */
|
|
53
|
+
const payphoneMetadataOf = (ctx, outcome) => {
|
|
54
|
+
const type = outcome.card_type ? CARD_TYPES[outcome.card_type.toLowerCase()] ?? null : null;
|
|
55
|
+
const reference = outcome.authorization_code ?? (outcome.transaction_id != null ? String(outcome.transaction_id) : null);
|
|
56
|
+
const p1 = { provider_transaction_id: outcome.transaction_id != null ? String(outcome.transaction_id) : null };
|
|
57
|
+
const p2 = {
|
|
58
|
+
authorization_code: outcome.authorization_code,
|
|
59
|
+
reference
|
|
60
|
+
};
|
|
61
|
+
const p3 = {
|
|
62
|
+
brand: outcome.card_brand,
|
|
63
|
+
card_type: type
|
|
64
|
+
};
|
|
65
|
+
const p4 = { last4: last4Of(outcome.last_digits) };
|
|
66
|
+
const payment = {
|
|
67
|
+
...p1,
|
|
68
|
+
...p2,
|
|
69
|
+
...p3,
|
|
70
|
+
...p4
|
|
71
|
+
};
|
|
72
|
+
const r1 = { transaction_id: outcome.transaction_id };
|
|
73
|
+
const r2 = { client_transaction_id: outcome.client_transaction_id };
|
|
74
|
+
const r3 = { status_code: outcome.status_code };
|
|
75
|
+
const provider = {
|
|
76
|
+
...r1,
|
|
77
|
+
...r2,
|
|
78
|
+
...r3,
|
|
79
|
+
deferred: outcome.deferred
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
attempt_reference: ctx.attemptReference,
|
|
83
|
+
payment,
|
|
84
|
+
provider
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const cardDetailsOf = (outcome) => {
|
|
88
|
+
const brand = outcome.card_brand;
|
|
89
|
+
const last4 = last4Of(outcome.last_digits);
|
|
90
|
+
if (!brand || !last4) return null;
|
|
91
|
+
const auth = outcome.authorization_code;
|
|
92
|
+
return {
|
|
93
|
+
method: "CARD",
|
|
94
|
+
brand,
|
|
95
|
+
last4,
|
|
96
|
+
...auth ? { auth_code: auth } : {}
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
/** Paid with the Payphone balance (no card) or a card inside the app. */
|
|
100
|
+
const walletDetailsOf = (outcome) => {
|
|
101
|
+
return {
|
|
102
|
+
method: "WALLET",
|
|
103
|
+
wallet: "payphone",
|
|
104
|
+
reference: outcome.authorization_code ?? String(outcome.transaction_id ?? "")
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
/** Spark error of a failed outcome: dictionary code, else `fallback`. */
|
|
108
|
+
const payphoneErrorOf = (ctx, outcome, fallback) => {
|
|
109
|
+
return toPaymentError(payphoneErrors, {
|
|
110
|
+
code: outcome.error_code ?? outcome.message_code,
|
|
111
|
+
message: outcome.message,
|
|
112
|
+
attempt: ctx.attempt,
|
|
113
|
+
fallback
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const failedResult = (ctx, error, metadata) => {
|
|
117
|
+
const base = metadata ?? { attempt_reference: ctx.attemptReference };
|
|
118
|
+
const review = error.code === "AMBIGUOUS" ? { needs_review: true } : {};
|
|
119
|
+
const m = {
|
|
120
|
+
...base,
|
|
121
|
+
...review,
|
|
122
|
+
error
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
status: "FAILED",
|
|
126
|
+
amount: ctx.amount.value,
|
|
127
|
+
metadata: m,
|
|
128
|
+
error
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
/** PAID / FAILED / PENDING result of a Payphone outcome. */
|
|
132
|
+
const payphoneResultOf = (ctx, outcome, kind) => {
|
|
133
|
+
const metadata = payphoneMetadataOf(ctx, outcome);
|
|
134
|
+
const amount = ctx.amount.value;
|
|
135
|
+
if (outcome.status === "PAID") return {
|
|
136
|
+
status: "PAID",
|
|
137
|
+
amount,
|
|
138
|
+
metadata,
|
|
139
|
+
details: kind === "CARD" ? cardDetailsOf(outcome) ?? walletDetailsOf(outcome) : walletDetailsOf(outcome)
|
|
140
|
+
};
|
|
141
|
+
if (outcome.status === "PENDING") return {
|
|
142
|
+
status: "PENDING",
|
|
143
|
+
amount,
|
|
144
|
+
metadata
|
|
145
|
+
};
|
|
146
|
+
return failedResult(ctx, payphoneErrorOf(ctx, outcome, kind === "CARD" ? "CARD_DECLINED" : "WALLET_REJECTED"), metadata);
|
|
147
|
+
};
|
|
148
|
+
/** Spark operation failures: a missing answer may still have charged. */
|
|
149
|
+
const serverErrorOf = (ctx, cause) => {
|
|
150
|
+
const e = cause;
|
|
151
|
+
const extra = {
|
|
152
|
+
provider_message: e?.message ?? "Payphone request failed",
|
|
153
|
+
attempt: ctx.attempt
|
|
154
|
+
};
|
|
155
|
+
if (e?.code === "AMBIGUOUS" || e?.status === 504) return createPaymentError("AMBIGUOUS", extra);
|
|
156
|
+
if (e?.status === 409) return createPaymentError("INVALID_CREDENTIALS", extra);
|
|
157
|
+
if (e?.status === 400) return createPaymentError("INVALID_REQUEST", extra);
|
|
158
|
+
return createPaymentError("PROVIDER_UNAVAILABLE", extra);
|
|
159
|
+
};
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/card.ts
|
|
162
|
+
/**
|
|
163
|
+
* `id` and `clientTransactionId` of a Payphone return URL (the page at
|
|
164
|
+
* `responseUrl`); `null` if it is not one.
|
|
165
|
+
*/
|
|
166
|
+
const parsePayphoneReturn = (url) => {
|
|
167
|
+
try {
|
|
168
|
+
const params = new URL(url).searchParams;
|
|
169
|
+
const id = Number(params.get("id"));
|
|
170
|
+
const clientTransactionId = params.get("clientTransactionId") ?? params.get("clientTxId");
|
|
171
|
+
if (!Number.isInteger(id) || id <= 0 || !clientTransactionId) return null;
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
clientTransactionId
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const storageKey = (attemptReference) => {
|
|
181
|
+
return `payphone:prepared:${attemptReference}`;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Payphone card on the web: Botón de Pago por redirección. Spark prepares
|
|
185
|
+
* the form with the token (`prepare`), the template opens it in a browser
|
|
186
|
+
* tab (`bridge`), and Spark confirms the return (`confirm`) within the 5
|
|
187
|
+
* minutes Payphone allows before it reverses. A paid charge is reversed by
|
|
188
|
+
* Spark with the order cancel or refund, the same day only. Web only:
|
|
189
|
+
* Payphone forbids its form in apps, iframes and WebViews.
|
|
190
|
+
*/
|
|
191
|
+
const payphoneCardPayments = (options) => {
|
|
192
|
+
const { bridge } = options;
|
|
193
|
+
const confirm = async (ctx, back) => {
|
|
194
|
+
if (back.clientTransactionId !== ctx.attemptReference) return failedResult(ctx, createPaymentError("INVALID_REQUEST", {
|
|
195
|
+
provider_message: "Payphone returned another attempt",
|
|
196
|
+
attempt: ctx.attempt
|
|
197
|
+
}));
|
|
198
|
+
ctx.ui.message({
|
|
199
|
+
text: "Confirmando pago…",
|
|
200
|
+
kind: "waiting"
|
|
201
|
+
});
|
|
202
|
+
const input = {
|
|
203
|
+
id: back.id,
|
|
204
|
+
client_transaction_id: back.clientTransactionId
|
|
205
|
+
};
|
|
206
|
+
return payphoneResultOf(ctx, await ctx.server.call("confirm", input), "CARD");
|
|
207
|
+
};
|
|
208
|
+
const pending = (ctx) => {
|
|
209
|
+
const metadata = { attempt_reference: ctx.attemptReference };
|
|
210
|
+
return {
|
|
211
|
+
status: "PENDING",
|
|
212
|
+
amount: ctx.amount.value,
|
|
213
|
+
metadata
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
const start = async (ctx) => {
|
|
217
|
+
try {
|
|
218
|
+
const r1 = { response_url: options.responseUrl };
|
|
219
|
+
const r2 = options.cancellationUrl ? { cancellation_url: options.cancellationUrl } : {};
|
|
220
|
+
const lang = options.language ?? "es";
|
|
221
|
+
const input = {
|
|
222
|
+
...r1,
|
|
223
|
+
...r2,
|
|
224
|
+
lang
|
|
225
|
+
};
|
|
226
|
+
const prepared = await ctx.server.call("prepare", input);
|
|
227
|
+
const url = options.payWith === "payphone" ? prepared.pay_with_payphone ?? prepared.pay_with_card : prepared.pay_with_card;
|
|
228
|
+
if (!url) return failedResult(ctx, createPaymentError("PROVIDER_UNAVAILABLE", {
|
|
229
|
+
provider_message: "Payphone returned no form",
|
|
230
|
+
attempt: ctx.attempt
|
|
231
|
+
}));
|
|
232
|
+
await ctx.storage.set(storageKey(ctx.attemptReference), url);
|
|
233
|
+
const b1 = {
|
|
234
|
+
url,
|
|
235
|
+
attemptReference: ctx.attemptReference
|
|
236
|
+
};
|
|
237
|
+
const b2 = {
|
|
238
|
+
responseUrl: options.responseUrl,
|
|
239
|
+
signal: ctx.signal
|
|
240
|
+
};
|
|
241
|
+
const outcome = await bridge.open({
|
|
242
|
+
...b1,
|
|
243
|
+
...b2
|
|
244
|
+
});
|
|
245
|
+
if (outcome.type === "redirected") return pending(ctx);
|
|
246
|
+
if (outcome.type === "cancelled") return failedResult(ctx, createPaymentError("CANCELLED_BY_USER", { attempt: ctx.attempt }));
|
|
247
|
+
return await confirm(ctx, outcome);
|
|
248
|
+
} catch (cause) {
|
|
249
|
+
if (cause instanceof PaymentError) return failedResult(ctx, cause.error);
|
|
250
|
+
return failedResult(ctx, serverErrorOf(ctx, cause));
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
/** After a full page redirect: confirms the return once it is there. */
|
|
254
|
+
const query = async (ctx, result) => {
|
|
255
|
+
if (result.status !== "PENDING") return result;
|
|
256
|
+
const back = await bridge.takeReturn?.(ctx.attemptReference) ?? null;
|
|
257
|
+
if (!back) return result;
|
|
258
|
+
try {
|
|
259
|
+
return await confirm(ctx, back);
|
|
260
|
+
} catch (cause) {
|
|
261
|
+
return failedResult(ctx, serverErrorOf(ctx, cause));
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
/** An unconfirmed charge is reversed by Payphone itself after 5 minutes. */
|
|
265
|
+
const cancel = async (_ctx, result) => {
|
|
266
|
+
return {
|
|
267
|
+
...result,
|
|
268
|
+
status: "CANCELLED"
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
const reversalFor = (result) => {
|
|
272
|
+
return result.status === "PAID" ? "server" : "cancel";
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
provider: PAYPHONE_PROVIDER,
|
|
276
|
+
method: "CARD",
|
|
277
|
+
errors: payphoneErrors,
|
|
278
|
+
policy: PAYPHONE_CARD_POLICY,
|
|
279
|
+
start,
|
|
280
|
+
query,
|
|
281
|
+
cancel,
|
|
282
|
+
reversalFor
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/wallet.ts
|
|
287
|
+
/** Sleeps `ms`, or less when `signal` aborts. */
|
|
288
|
+
const wait = (ms, signal) => {
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
if (signal.aborted) return resolve();
|
|
291
|
+
const timer = setTimeout(done, ms);
|
|
292
|
+
function done() {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
signal.removeEventListener("abort", done);
|
|
295
|
+
resolve();
|
|
296
|
+
}
|
|
297
|
+
signal.addEventListener("abort", done);
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Payphone app push (API Sale) for kiosks and mobile apps: Spark sends
|
|
302
|
+
* the charge to the customer's Payphone app, the customer approves it
|
|
303
|
+
* there (balance or a card saved in the app) within 5 minutes, and the
|
|
304
|
+
* adapter reads the status. A push nobody approves is withdrawn. A paid
|
|
305
|
+
* charge is reversed by Spark with the order cancel or refund, the same
|
|
306
|
+
* day only.
|
|
307
|
+
*/
|
|
308
|
+
const payphoneWalletPayments = (options = {}) => {
|
|
309
|
+
const label = options.phoneLabel ?? "Celular registrado en Payphone";
|
|
310
|
+
const phoneOf = async (ctx) => {
|
|
311
|
+
if (options.phone) return options.phone(ctx);
|
|
312
|
+
if (ctx.customer.phone) return ctx.customer.phone;
|
|
313
|
+
return ctx.ui.input({
|
|
314
|
+
label,
|
|
315
|
+
type: "number",
|
|
316
|
+
maxLength: 15
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
const status = (ctx) => {
|
|
320
|
+
return ctx.server.call("sale_status", {});
|
|
321
|
+
};
|
|
322
|
+
/** Withdraws the push; a refused cancel means it already settled. */
|
|
323
|
+
const withdraw = async (ctx) => {
|
|
324
|
+
await ctx.server.call("sale_cancel", {}).catch(() => null);
|
|
325
|
+
return status(ctx).catch(() => null);
|
|
326
|
+
};
|
|
327
|
+
const poll = async (ctx) => {
|
|
328
|
+
const deadline = Date.now() + PAYPHONE_SALE_TTL_MS;
|
|
329
|
+
const every = ctx.policy.poll_interval_ms || 3e3;
|
|
330
|
+
while (Date.now() < deadline && !ctx.signal.aborted) {
|
|
331
|
+
await wait(every, ctx.signal);
|
|
332
|
+
if (ctx.signal.aborted) break;
|
|
333
|
+
const outcome = await status(ctx).catch(() => null);
|
|
334
|
+
if (outcome && outcome.status !== "PENDING") return payphoneResultOf(ctx, outcome, "WALLET");
|
|
335
|
+
}
|
|
336
|
+
const last = await withdraw(ctx);
|
|
337
|
+
if (last && last.status !== "PENDING") return payphoneResultOf(ctx, last, "WALLET");
|
|
338
|
+
return failedResult(ctx, createPaymentError(ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED", { attempt: ctx.attempt }));
|
|
339
|
+
};
|
|
340
|
+
const start = async (ctx) => {
|
|
341
|
+
try {
|
|
342
|
+
const phone = (await phoneOf(ctx))?.trim();
|
|
343
|
+
if (!phone) return failedResult(ctx, createPaymentError("CANCELLED_BY_USER", { attempt: ctx.attempt }));
|
|
344
|
+
const sent = await ctx.server.call("sale", { phone_number: phone }).catch((cause) => {
|
|
345
|
+
if (serverErrorOf(ctx, cause).code === "AMBIGUOUS") return null;
|
|
346
|
+
throw cause;
|
|
347
|
+
});
|
|
348
|
+
if (sent?.status === "FAILED") return payphoneResultOf(ctx, sent, "WALLET");
|
|
349
|
+
ctx.ui.message({
|
|
350
|
+
text: "Aprueba el pago en tu app Payphone",
|
|
351
|
+
kind: "waiting"
|
|
352
|
+
});
|
|
353
|
+
return await poll(ctx);
|
|
354
|
+
} catch (cause) {
|
|
355
|
+
if (cause instanceof PaymentError) return failedResult(ctx, cause.error);
|
|
356
|
+
return failedResult(ctx, serverErrorOf(ctx, cause));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const query = async (ctx) => {
|
|
360
|
+
return payphoneResultOf(ctx, await status(ctx), "WALLET");
|
|
361
|
+
};
|
|
362
|
+
const cancel = async (ctx, result) => {
|
|
363
|
+
await ctx.server.call("sale_cancel", {}).catch(() => null);
|
|
364
|
+
return {
|
|
365
|
+
...result,
|
|
366
|
+
status: "CANCELLED"
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
const reversalFor = (result) => {
|
|
370
|
+
return result.status === "PAID" ? "server" : "cancel";
|
|
371
|
+
};
|
|
372
|
+
return {
|
|
373
|
+
provider: PAYPHONE_PROVIDER,
|
|
374
|
+
method: "WALLET",
|
|
375
|
+
errors: payphoneErrors,
|
|
376
|
+
policy: PAYPHONE_WALLET_POLICY,
|
|
377
|
+
start,
|
|
378
|
+
query,
|
|
379
|
+
cancel,
|
|
380
|
+
reversalFor
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
//#endregion
|
|
384
|
+
export { PAYPHONE_CARD_POLICY, PAYPHONE_PROVIDER, PAYPHONE_SALE_TTL_MS, PAYPHONE_WALLET_POLICY, parsePayphoneReturn, payphoneCardPayments, payphoneErrors, payphoneWalletPayments };
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fire-spark/payments-payphone-checkout",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"description": "Fire Spark Payphone adapters (Ecuador): card through the Botón de Pago redirect on the web, and the Payphone app push (API Sale) for kiosks and mobile apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@fire-spark/types": "^0.15.0",
|
|
21
|
+
"@fire-spark/payments": "^0.15.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsdown": "^0.22.14",
|
|
25
|
+
"typescript": "^7.0.2",
|
|
26
|
+
"vitest": "^2.1.8"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"tsdown": {
|
|
32
|
+
"entry": [
|
|
33
|
+
"src/index.ts"
|
|
34
|
+
],
|
|
35
|
+
"format": [
|
|
36
|
+
"esm"
|
|
37
|
+
],
|
|
38
|
+
"platform": "neutral",
|
|
39
|
+
"dts": {
|
|
40
|
+
"tsconfig": "tsconfig.build.json"
|
|
41
|
+
},
|
|
42
|
+
"clean": true,
|
|
43
|
+
"deps": {
|
|
44
|
+
"neverBundle": [
|
|
45
|
+
"@fire-spark/payments",
|
|
46
|
+
"@fire-spark/types"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"dev": "tsdown --watch",
|
|
53
|
+
"check-types": "tsc --noEmit",
|
|
54
|
+
"test:utils": "vitest run",
|
|
55
|
+
"lint": "oxlint ./src --max-warnings=0",
|
|
56
|
+
"format": "oxfmt ./src",
|
|
57
|
+
"check-format": "oxfmt --check ./src"
|
|
58
|
+
}
|
|
6
59
|
}
|
package/README.md
DELETED