@devly-cl/billing-fintoc 0.1.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/README.md +64 -0
- package/dist/fintoc-billing.provider.d.ts +38 -0
- package/dist/fintoc-billing.provider.js +422 -0
- package/dist/fintoc-http.client.d.ts +27 -0
- package/dist/fintoc-http.client.js +237 -0
- package/dist/fintoc-provider.options.d.ts +18 -0
- package/dist/fintoc-provider.options.js +2 -0
- package/dist/fintoc-subscription.gateway.d.ts +30 -0
- package/dist/fintoc-subscription.gateway.js +282 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @devly-cl/billing-fintoc
|
|
2
|
+
|
|
3
|
+
Fintoc billing adapter for Node 24+. Depends only on `@devly-cl/billing-core`; CommonJS, ESM consumers and NodeNext declarations share the same provider and core error constructors. Published releases are installed from npm; local `npm pack` only creates a candidate tarball.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @devly-cl/billing-core @devly-cl/billing-fintoc
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Public API
|
|
12
|
+
|
|
13
|
+
The runtime entry exports `FintocBillingProvider`. Type exports are `FintocBillingProviderOptions`, `FintocRequestContext` and `FintocTransport`. Import operation inputs, results, capabilities and `SubscriptionGatewayError` from `@devly-cl/billing-core`. The HTTP client, subscription mapper and parsers are internal; deep imports are blocked.
|
|
14
|
+
|
|
15
|
+
Construct the provider with a fixed `environment` (`test` or `live`) and getters `getSecretKey()`, `getWebhookSecret()` and `getReturnUrl(kind)` (`success` or `cancel`). Construction does not validate credentials or contact Fintoc. Getters run when the operation requires them; credential rotation never changes the instance's environment. Binding and capability values are frozen.
|
|
16
|
+
|
|
17
|
+
Optional `transport(url, init)` replaces native fetch and `now()` supplies milliseconds since epoch for webhook expiry. The declaration includes fetch platform types explicitly and does not require Nest, TypeORM or implicit Node type dependencies in consumers.
|
|
18
|
+
|
|
19
|
+
The host implements `beforeRequest({ method, path, collection })` for sales policy. New mutations have `collection: true`; GET and subscription cancellation have `collection: false`. Throw a safe core error to block an operation before dispatch. Environment-specific approval checks can live in the secret getter. Selection, legacy fallback, secret storage and authorization remain host responsibilities.
|
|
20
|
+
|
|
21
|
+
`assertReady()` and `assertReady('subscriptionCheckout')` preserve credential-only readiness. `assertReady('paymentMethodSetup')` additionally checks collection policy and both return URLs. Checkout and setup operations always validate their actual return URLs. General availability therefore does not promise that a hosted setup can start.
|
|
22
|
+
|
|
23
|
+
## Behavior
|
|
24
|
+
|
|
25
|
+
Subscription checkout, canonical subscription/invoice reads, cancellation, recurring terms, one-off draft/finalize invoices and payment-method setup/change implement the core provider port. Mutations carry `Idempotency-Key`; wire calls use `/v2`, `Fintoc-Version: 2026-02-01`, a 15-second timeout, JSON and redirect rejection. Each call makes one attempt. Transport/JSON failures and mutation HTTP 408/409/429/5xx remain uncertain; successful mutation responses with invalid canonical evidence are also uncertain. No response body, URL or transport cause is attached to errors.
|
|
26
|
+
|
|
27
|
+
`verifyEvent(rawBody, signature)` verifies the original bytes, HMAC, timestamp within 300 seconds, multiple rotation signatures and pinned mode. Webhooks are capped at 1 MiB; invoice pages at 50 entries. Verification returns a canonical event hint, not payment confirmation. Durable inboxes, retries, tenant ownership, database transactions, RBAC and audit stay in the host; the package does not persist state or retry automatically.
|
|
28
|
+
|
|
29
|
+
## Integration example
|
|
30
|
+
|
|
31
|
+
The host supplies credentials and policy; the declarations below are integration requirements, not working credentials. Constructing the adapter and checking readiness make no provider request.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { SubscriptionGatewayError } from '@devly-cl/billing-core';
|
|
35
|
+
import { FintocBillingProvider } from '@devly-cl/billing-fintoc';
|
|
36
|
+
|
|
37
|
+
declare const host: {
|
|
38
|
+
testSecretKey(): string;
|
|
39
|
+
testWebhookSecret(): string;
|
|
40
|
+
returnUrl(kind: 'success' | 'cancel'): string;
|
|
41
|
+
collectionEnabled(): boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const provider = new FintocBillingProvider({
|
|
45
|
+
environment: 'test',
|
|
46
|
+
getSecretKey: () => host.testSecretKey(),
|
|
47
|
+
getWebhookSecret: () => host.testWebhookSecret(),
|
|
48
|
+
getReturnUrl: (kind) => host.returnUrl(kind),
|
|
49
|
+
beforeRequest: ({ collection }) => {
|
|
50
|
+
if (collection && !host.collectionEnabled()) {
|
|
51
|
+
throw new SubscriptionGatewayError('BILLING_PROVIDER_DISABLED');
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
provider.assertReady('paymentMethodSetup');
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use a separate instance and credential getters for live. Implement environment approval in those getters when required by the host. Keep maintenance reads/cancellation and new collection policy distinct; retain stricter existing live gates when migrating an application. For webhooks, pass the original bytes to `verifyEvent` and durably enqueue the verified event before acknowledging HTTP; later reconciliation must establish canonical invoice evidence.
|
|
59
|
+
|
|
60
|
+
## Validation
|
|
61
|
+
|
|
62
|
+
Build from this repository root with `npm run build:packages`. Run `npm run test:packages`, `npm run test:package-boundaries` and `npm run test:package-artifacts`. The artifact consumer installs only Fintoc and core tarballs offline, verifies a synthetic operation/signature and CJS/ESM/NodeNext compatibility. These checks do not demonstrate a real PSP transaction or production credential readiness.
|
|
63
|
+
|
|
64
|
+
During development, run `npm run build:packages` after changing package sources, then restart Nest. `start:dev` builds packages once at startup; automatic package hot reload has not been validated.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FintocBillingProviderOptions } from './fintoc-provider.options';
|
|
2
|
+
import type { SubscriptionCheckoutInput } from '@devly-cl/billing-core';
|
|
3
|
+
import type { BillingProviderCapability, BillingProviderPort, ChangeRecurringTermsInput, CreateOneOffInvoiceInput, CreatePaymentMethodSetupInput, FinalizeOneOffInvoiceInput, ProviderBinding, ProviderOneOffInvoice, ProviderPaymentMethodSetup, ProviderRecurringTerms, SetSubscriptionPaymentMethodInput, VerifiedBillingEvent } from '@devly-cl/billing-core';
|
|
4
|
+
/** Fintoc API 2026-02-01. Each instance is permanently bound to one environment. */
|
|
5
|
+
export declare class FintocBillingProvider implements BillingProviderPort {
|
|
6
|
+
readonly webhookSignatureHeader = "fintoc-signature";
|
|
7
|
+
readonly binding: ProviderBinding;
|
|
8
|
+
readonly capabilities: readonly BillingProviderCapability[];
|
|
9
|
+
private readonly transport;
|
|
10
|
+
private readonly subscriptions;
|
|
11
|
+
constructor(options: FintocBillingProviderOptions);
|
|
12
|
+
assertReady(capability?: BillingProviderCapability): void;
|
|
13
|
+
createCheckout(input: SubscriptionCheckoutInput): Promise<import("@devly-cl/billing-core").SubscriptionCheckout>;
|
|
14
|
+
getCheckout(id: string): Promise<import("@devly-cl/billing-core").SubscriptionCheckout>;
|
|
15
|
+
getSubscription(id: string): Promise<import("@devly-cl/billing-core").ProviderSubscription>;
|
|
16
|
+
cancelSubscription(id: string, key: string): Promise<import("@devly-cl/billing-core").ProviderSubscription>;
|
|
17
|
+
getInvoice(id: string): Promise<import("@devly-cl/billing-core").ProviderInvoice>;
|
|
18
|
+
listInvoices(id: string, cursor?: string): Promise<{
|
|
19
|
+
items: import("@devly-cl/billing-core").ProviderInvoice[];
|
|
20
|
+
nextCursor: string | null;
|
|
21
|
+
}>;
|
|
22
|
+
verifyEvent(rawBody: Uint8Array, signature: string | undefined): VerifiedBillingEvent;
|
|
23
|
+
getRecurringTerms(subscriptionId: string): Promise<ProviderRecurringTerms>;
|
|
24
|
+
changeRecurringTerms(input: ChangeRecurringTermsInput): Promise<ProviderRecurringTerms>;
|
|
25
|
+
createOneOffInvoice(input: CreateOneOffInvoiceInput): Promise<ProviderOneOffInvoice>;
|
|
26
|
+
getOneOffInvoice(invoiceId: string): Promise<ProviderOneOffInvoice>;
|
|
27
|
+
finalizeOneOffInvoice(input: FinalizeOneOffInvoiceInput): Promise<ProviderOneOffInvoice>;
|
|
28
|
+
createPaymentMethodSetup(input: CreatePaymentMethodSetupInput): Promise<ProviderPaymentMethodSetup>;
|
|
29
|
+
getPaymentMethodSetup(setupId: string): Promise<ProviderPaymentMethodSetup>;
|
|
30
|
+
setSubscriptionPaymentMethod(input: SetSubscriptionPaymentMethodInput): Promise<ProviderRecurringTerms>;
|
|
31
|
+
private recurringItem;
|
|
32
|
+
private assertSubscriptionOwner;
|
|
33
|
+
private assertPaymentMethod;
|
|
34
|
+
private oneOffInvoice;
|
|
35
|
+
private assertInvoice;
|
|
36
|
+
private paymentMethodSetup;
|
|
37
|
+
private confirmSetup;
|
|
38
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FintocBillingProvider = void 0;
|
|
4
|
+
const billing_core_1 = require("@devly-cl/billing-core");
|
|
5
|
+
const fintoc_subscription_gateway_1 = require("./fintoc-subscription.gateway");
|
|
6
|
+
const fintoc_http_client_1 = require("./fintoc-http.client");
|
|
7
|
+
const validAmount = (amount, currency) => {
|
|
8
|
+
if (!Number.isSafeInteger(amount) || amount < 1 || currency !== 'CLP')
|
|
9
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_TERMS_INVALID');
|
|
10
|
+
};
|
|
11
|
+
const validLabel = (value) => {
|
|
12
|
+
if (typeof value !== 'string' ||
|
|
13
|
+
!value.trim() ||
|
|
14
|
+
value.length > 200 ||
|
|
15
|
+
Array.from(value).some((character) => character.charCodeAt(0) < 32))
|
|
16
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_TERMS_INVALID');
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
const operationId = (value) => {
|
|
20
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9:._-]{1,128}$/.test(value))
|
|
21
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_OPERATION_ID_INVALID');
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
const utcDate = (value) => {
|
|
25
|
+
if (typeof value !== 'string' ||
|
|
26
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value) ||
|
|
27
|
+
!Number.isFinite(Date.parse(value)))
|
|
28
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PERIOD_INVALID');
|
|
29
|
+
const result = new Date(value).toISOString();
|
|
30
|
+
if (result.slice(0, 19) !== value.slice(0, 19))
|
|
31
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PERIOD_INVALID');
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
const period = (start, end) => {
|
|
35
|
+
const result = { start: utcDate(start), end: utcDate(end) };
|
|
36
|
+
if (result.start >= result.end)
|
|
37
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PERIOD_INVALID');
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
/** Fintoc API 2026-02-01. Each instance is permanently bound to one environment. */
|
|
41
|
+
class FintocBillingProvider {
|
|
42
|
+
webhookSignatureHeader = 'fintoc-signature';
|
|
43
|
+
binding;
|
|
44
|
+
capabilities = Object.freeze([
|
|
45
|
+
'subscriptionCheckout',
|
|
46
|
+
'recurringPriceChange',
|
|
47
|
+
'recurringIntervalChange',
|
|
48
|
+
'oneOffInvoice',
|
|
49
|
+
'paymentMethodSetup',
|
|
50
|
+
'paymentMethodChange',
|
|
51
|
+
'webhookVerification',
|
|
52
|
+
]);
|
|
53
|
+
transport;
|
|
54
|
+
subscriptions;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
const environment = options.environment;
|
|
57
|
+
if (!['test', 'live'].includes(environment))
|
|
58
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_BINDING_INVALID');
|
|
59
|
+
this.binding = Object.freeze({ provider: 'fintoc', environment });
|
|
60
|
+
this.transport = new fintoc_http_client_1.FintocHttpClient(options, environment);
|
|
61
|
+
this.subscriptions = new fintoc_subscription_gateway_1.FintocSubscriptionGateway(this.transport);
|
|
62
|
+
}
|
|
63
|
+
assertReady(capability) {
|
|
64
|
+
if (capability === 'paymentMethodSetup') {
|
|
65
|
+
this.transport.assertCollectionReady();
|
|
66
|
+
this.transport.returnUrl('success');
|
|
67
|
+
this.transport.returnUrl('cancel');
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
this.transport.credentials();
|
|
71
|
+
}
|
|
72
|
+
createCheckout(input) {
|
|
73
|
+
return this.subscriptions.createCheckout(input);
|
|
74
|
+
}
|
|
75
|
+
getCheckout(id) {
|
|
76
|
+
return this.subscriptions.getCheckout(id);
|
|
77
|
+
}
|
|
78
|
+
getSubscription(id) {
|
|
79
|
+
return this.subscriptions.getSubscription(id);
|
|
80
|
+
}
|
|
81
|
+
cancelSubscription(id, key) {
|
|
82
|
+
return this.subscriptions.cancelSubscription(id, key);
|
|
83
|
+
}
|
|
84
|
+
getInvoice(id) {
|
|
85
|
+
return this.subscriptions.getInvoice(id);
|
|
86
|
+
}
|
|
87
|
+
listInvoices(id, cursor) {
|
|
88
|
+
return this.subscriptions.listInvoices(id, cursor);
|
|
89
|
+
}
|
|
90
|
+
verifyEvent(rawBody, signature) {
|
|
91
|
+
if (!(rawBody instanceof Uint8Array) || rawBody.byteLength > 1_048_576)
|
|
92
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_INVALID');
|
|
93
|
+
return this.subscriptions.verifyWebhook(Buffer.from(rawBody), signature);
|
|
94
|
+
}
|
|
95
|
+
async getRecurringTerms(subscriptionId) {
|
|
96
|
+
const id = (0, fintoc_http_client_1.fintocIdentifier)(subscriptionId, 'sub');
|
|
97
|
+
const data = (0, fintoc_http_client_1.fintocObject)(await this.transport.request(`/subscriptions/${id}`));
|
|
98
|
+
if ((0, fintoc_http_client_1.fintocIdentifier)(data.id, 'sub') !== id)
|
|
99
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_MISMATCH');
|
|
100
|
+
if (!Array.isArray(data.items) || data.items.length !== 1)
|
|
101
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_UNSUPPORTED');
|
|
102
|
+
const item = this.recurringItem(data.items[0]);
|
|
103
|
+
const status = (0, fintoc_http_client_1.fintocString)(data.status);
|
|
104
|
+
if (!['active', 'incomplete', 'trialing', 'canceled'].includes(status))
|
|
105
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_UNSUPPORTED');
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
customerId: (0, fintoc_http_client_1.fintocIdentifier)(data.customer, 'cus'),
|
|
109
|
+
mode: this.transport.mode(data.mode),
|
|
110
|
+
status,
|
|
111
|
+
...item,
|
|
112
|
+
paymentMethodId: data.payment_method === null
|
|
113
|
+
? null
|
|
114
|
+
: (0, fintoc_http_client_1.fintocIdentifier)(data.payment_method, 'pm'),
|
|
115
|
+
billingCycleAnchor: utcDate(data.billing_cycle_anchor),
|
|
116
|
+
periodStart: null,
|
|
117
|
+
periodEnd: null,
|
|
118
|
+
nextInvoiceAt: null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async changeRecurringTerms(input) {
|
|
122
|
+
(0, fintoc_http_client_1.billingIdempotencyKey)(input.idempotencyKey);
|
|
123
|
+
validAmount(input.amount, input.currency);
|
|
124
|
+
validAmount(input.expectedCurrent.amount, input.currency);
|
|
125
|
+
validLabel(input.label);
|
|
126
|
+
if (!['month', 'year'].includes(input.interval) ||
|
|
127
|
+
!['month', 'year'].includes(input.expectedCurrent.interval))
|
|
128
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_TERMS_INVALID');
|
|
129
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.itemId, 'si');
|
|
130
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.customerId, 'cus');
|
|
131
|
+
const current = await this.getRecurringTerms(input.subscriptionId);
|
|
132
|
+
this.assertSubscriptionOwner(current, input.customerId);
|
|
133
|
+
if (input.expectedBillingCycleAnchor &&
|
|
134
|
+
current.billingCycleAnchor !== utcDate(input.expectedBillingCycleAnchor))
|
|
135
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_BILLING_ANCHOR_MISMATCH');
|
|
136
|
+
if (current.itemId !== input.itemId)
|
|
137
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_MISMATCH');
|
|
138
|
+
if (current.amount === input.amount && current.interval === input.interval)
|
|
139
|
+
return current;
|
|
140
|
+
if (current.amount !== input.expectedCurrent.amount ||
|
|
141
|
+
current.interval !== input.expectedCurrent.interval)
|
|
142
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_TERMS_CHANGED');
|
|
143
|
+
return this.transport.mutation(() => this.transport.request(`/subscriptions/${current.id}/items/${current.itemId}`, 'PATCH', {
|
|
144
|
+
price_data: {
|
|
145
|
+
currency: input.currency,
|
|
146
|
+
unit_amount: input.amount,
|
|
147
|
+
product_data: { name: input.label },
|
|
148
|
+
recurring: { interval: input.interval, interval_count: 1 },
|
|
149
|
+
},
|
|
150
|
+
quantity: 1,
|
|
151
|
+
}, input.idempotencyKey), async (raw) => {
|
|
152
|
+
const item = this.recurringItem(raw);
|
|
153
|
+
if (item.itemId !== current.itemId ||
|
|
154
|
+
item.amount !== input.amount ||
|
|
155
|
+
item.interval !== input.interval)
|
|
156
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_TERMS_MISMATCH');
|
|
157
|
+
const updated = await this.getRecurringTerms(current.id);
|
|
158
|
+
this.assertSubscriptionOwner(updated, input.customerId);
|
|
159
|
+
if (updated.billingCycleAnchor !== current.billingCycleAnchor)
|
|
160
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_BILLING_ANCHOR_MISMATCH');
|
|
161
|
+
if (updated.itemId !== current.itemId ||
|
|
162
|
+
updated.amount !== input.amount ||
|
|
163
|
+
updated.interval !== input.interval)
|
|
164
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_TERMS_MISMATCH');
|
|
165
|
+
return updated;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async createOneOffInvoice(input) {
|
|
169
|
+
(0, fintoc_http_client_1.billingIdempotencyKey)(input.idempotencyKey);
|
|
170
|
+
validAmount(input.amount, input.currency);
|
|
171
|
+
validLabel(input.label);
|
|
172
|
+
operationId(input.localOperationId);
|
|
173
|
+
const servicePeriod = period(input.periodStart, input.periodEnd);
|
|
174
|
+
await this.assertPaymentMethod(input.paymentMethodId, input.customerId);
|
|
175
|
+
return this.transport.mutation(() => this.transport.request('/invoices', 'POST', {
|
|
176
|
+
customer: input.customerId,
|
|
177
|
+
default_payment_method: input.paymentMethodId,
|
|
178
|
+
collection_method: 'charge_automatically',
|
|
179
|
+
lines: [
|
|
180
|
+
{
|
|
181
|
+
name: input.label,
|
|
182
|
+
amount: input.amount,
|
|
183
|
+
currency: input.currency,
|
|
184
|
+
quantity: 1,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
metadata: {
|
|
188
|
+
operation_external_id: input.localOperationId,
|
|
189
|
+
service_period_start: servicePeriod.start,
|
|
190
|
+
service_period_end: servicePeriod.end,
|
|
191
|
+
},
|
|
192
|
+
}, input.idempotencyKey), (raw) => {
|
|
193
|
+
const invoice = this.oneOffInvoice(raw);
|
|
194
|
+
this.assertInvoice(invoice, input);
|
|
195
|
+
return invoice;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async getOneOffInvoice(invoiceId) {
|
|
199
|
+
const id = (0, fintoc_http_client_1.fintocIdentifier)(invoiceId, 'inv');
|
|
200
|
+
const invoice = this.oneOffInvoice(await this.transport.request(`/invoices/${id}`));
|
|
201
|
+
if (invoice.id !== id)
|
|
202
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
203
|
+
return invoice;
|
|
204
|
+
}
|
|
205
|
+
async finalizeOneOffInvoice(input) {
|
|
206
|
+
(0, fintoc_http_client_1.billingIdempotencyKey)(input.idempotencyKey);
|
|
207
|
+
validAmount(input.amount, input.currency);
|
|
208
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.customerId, 'cus');
|
|
209
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.paymentMethodId, 'pm');
|
|
210
|
+
operationId(input.localOperationId);
|
|
211
|
+
period(input.periodStart, input.periodEnd);
|
|
212
|
+
const current = await this.getOneOffInvoice(input.invoiceId);
|
|
213
|
+
this.assertInvoice(current, input);
|
|
214
|
+
if (current.status === 'open' || current.status === 'paid')
|
|
215
|
+
return current;
|
|
216
|
+
if (current.status !== 'draft')
|
|
217
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_NOT_PAYABLE');
|
|
218
|
+
return this.transport.mutation(() => this.transport.request(`/invoices/${current.id}/finalize`, 'POST', {}, input.idempotencyKey), (raw) => {
|
|
219
|
+
const invoice = this.oneOffInvoice(raw);
|
|
220
|
+
this.assertInvoice(invoice, input);
|
|
221
|
+
if (invoice.id !== current.id ||
|
|
222
|
+
!['open', 'paid'].includes(invoice.status))
|
|
223
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
224
|
+
return invoice;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async createPaymentMethodSetup(input) {
|
|
228
|
+
(0, fintoc_http_client_1.billingIdempotencyKey)(input.idempotencyKey);
|
|
229
|
+
operationId(input.localOperationId);
|
|
230
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.customerId, 'cus');
|
|
231
|
+
const successUrl = this.transport.returnUrl('success');
|
|
232
|
+
const cancelUrl = this.transport.returnUrl('cancel');
|
|
233
|
+
const subscription = await this.getRecurringTerms(input.subscriptionId);
|
|
234
|
+
this.assertSubscriptionOwner(subscription, input.customerId);
|
|
235
|
+
return this.transport.mutation(() => this.transport.request('/checkout_sessions', 'POST', {
|
|
236
|
+
flow: 'setup',
|
|
237
|
+
subscription: subscription.id,
|
|
238
|
+
customer: input.customerId,
|
|
239
|
+
currency: 'CLP',
|
|
240
|
+
payment_method_types: ['pac', 'card'],
|
|
241
|
+
success_url: successUrl,
|
|
242
|
+
cancel_url: cancelUrl,
|
|
243
|
+
metadata: { operation_external_id: input.localOperationId },
|
|
244
|
+
}, input.idempotencyKey), async (raw) => {
|
|
245
|
+
const setup = this.paymentMethodSetup(raw);
|
|
246
|
+
if (setup.customerId !== input.customerId ||
|
|
247
|
+
setup.subscriptionId !== subscription.id ||
|
|
248
|
+
setup.localOperationId !== input.localOperationId)
|
|
249
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SETUP_MISMATCH');
|
|
250
|
+
if (setup.status === 'finished')
|
|
251
|
+
await this.confirmSetup(setup);
|
|
252
|
+
return setup;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async getPaymentMethodSetup(setupId) {
|
|
256
|
+
const id = (0, fintoc_http_client_1.fintocIdentifier)(setupId, 'cs');
|
|
257
|
+
const setup = this.paymentMethodSetup(await this.transport.request(`/checkout_sessions/${id}`));
|
|
258
|
+
if (setup.id !== id)
|
|
259
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SETUP_MISMATCH');
|
|
260
|
+
if (setup.status === 'finished')
|
|
261
|
+
await this.confirmSetup(setup);
|
|
262
|
+
return setup;
|
|
263
|
+
}
|
|
264
|
+
async setSubscriptionPaymentMethod(input) {
|
|
265
|
+
(0, fintoc_http_client_1.billingIdempotencyKey)(input.idempotencyKey);
|
|
266
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.customerId, 'cus');
|
|
267
|
+
(0, fintoc_http_client_1.fintocIdentifier)(input.paymentMethodId, 'pm');
|
|
268
|
+
const current = await this.getRecurringTerms(input.subscriptionId);
|
|
269
|
+
this.assertSubscriptionOwner(current, input.customerId);
|
|
270
|
+
await this.assertPaymentMethod(input.paymentMethodId, input.customerId);
|
|
271
|
+
if (current.paymentMethodId === input.paymentMethodId)
|
|
272
|
+
return current;
|
|
273
|
+
return this.transport.mutation(() => this.transport.request(`/subscriptions/${current.id}`, 'PATCH', { payment_method: input.paymentMethodId }, input.idempotencyKey), async (value) => {
|
|
274
|
+
const raw = (0, fintoc_http_client_1.fintocObject)(value);
|
|
275
|
+
if ((0, fintoc_http_client_1.fintocIdentifier)(raw.id, 'sub') !== current.id ||
|
|
276
|
+
(0, fintoc_http_client_1.fintocIdentifier)(raw.customer, 'cus') !== input.customerId ||
|
|
277
|
+
(0, fintoc_http_client_1.fintocIdentifier)(raw.payment_method, 'pm') !== input.paymentMethodId)
|
|
278
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PAYMENT_METHOD_MISMATCH');
|
|
279
|
+
this.transport.mode(raw.mode);
|
|
280
|
+
const updated = await this.getRecurringTerms(current.id);
|
|
281
|
+
this.assertSubscriptionOwner(updated, input.customerId);
|
|
282
|
+
if (updated.paymentMethodId !== input.paymentMethodId)
|
|
283
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PAYMENT_METHOD_MISMATCH');
|
|
284
|
+
return updated;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
recurringItem(value) {
|
|
288
|
+
const item = (0, fintoc_http_client_1.fintocObject)(value), price = (0, fintoc_http_client_1.fintocObject)(item.price), recurring = (0, fintoc_http_client_1.fintocObject)(price.recurring);
|
|
289
|
+
if ((0, fintoc_http_client_1.fintocInteger)(item.quantity) !== 1 ||
|
|
290
|
+
price.currency !== 'CLP' ||
|
|
291
|
+
(0, fintoc_http_client_1.fintocInteger)(recurring.interval_count) !== 1 ||
|
|
292
|
+
!['month', 'year'].includes(String(recurring.interval)))
|
|
293
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_UNSUPPORTED');
|
|
294
|
+
const amount = (0, fintoc_http_client_1.fintocInteger)(price.unit_amount);
|
|
295
|
+
validAmount(amount, price.currency);
|
|
296
|
+
return {
|
|
297
|
+
itemId: (0, fintoc_http_client_1.fintocIdentifier)(item.id, 'si'),
|
|
298
|
+
amount,
|
|
299
|
+
currency: 'CLP',
|
|
300
|
+
interval: recurring.interval,
|
|
301
|
+
quantity: 1,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
assertSubscriptionOwner(subscription, customerId) {
|
|
305
|
+
if (subscription.customerId !== customerId)
|
|
306
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_CUSTOMER_MISMATCH');
|
|
307
|
+
if (subscription.status === 'canceled')
|
|
308
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_NOT_EDITABLE');
|
|
309
|
+
}
|
|
310
|
+
async assertPaymentMethod(paymentMethodId, customerId) {
|
|
311
|
+
const id = (0, fintoc_http_client_1.fintocIdentifier)(paymentMethodId, 'pm');
|
|
312
|
+
(0, fintoc_http_client_1.fintocIdentifier)(customerId, 'cus');
|
|
313
|
+
const method = (0, fintoc_http_client_1.fintocObject)(await this.transport.request(`/payment_methods/${id}`));
|
|
314
|
+
this.transport.mode(method.mode);
|
|
315
|
+
if ((0, fintoc_http_client_1.fintocIdentifier)(method.id, 'pm') !== id ||
|
|
316
|
+
(0, fintoc_http_client_1.fintocIdentifier)(method.customer, 'cus') !== customerId)
|
|
317
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PAYMENT_METHOD_MISMATCH');
|
|
318
|
+
if (!((method.type === 'pac' && (0, fintoc_http_client_1.fintocObject)(method.pac).status === 'active') ||
|
|
319
|
+
(method.type === 'card' && (0, fintoc_http_client_1.fintocObject)(method.card).active === true)))
|
|
320
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_PAYMENT_METHOD_UNAVAILABLE');
|
|
321
|
+
}
|
|
322
|
+
oneOffInvoice(value) {
|
|
323
|
+
const data = (0, fintoc_http_client_1.fintocObject)(value);
|
|
324
|
+
if (data.subscription !== null ||
|
|
325
|
+
data.collection_method !== 'charge_automatically' ||
|
|
326
|
+
!Array.isArray(data.lines) ||
|
|
327
|
+
data.lines.length !== 1 ||
|
|
328
|
+
!Array.isArray(data.payments) ||
|
|
329
|
+
!['draft', 'open', 'paid', 'void'].includes(String(data.status)))
|
|
330
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_UNSUPPORTED');
|
|
331
|
+
const line = (0, fintoc_http_client_1.fintocObject)(data.lines[0]), metadata = (0, fintoc_http_client_1.fintocObject)(data.metadata);
|
|
332
|
+
const amount = (0, fintoc_http_client_1.fintocInteger)(data.total);
|
|
333
|
+
validAmount(amount, (0, fintoc_http_client_1.fintocString)(data.currency));
|
|
334
|
+
if ((0, fintoc_http_client_1.fintocInteger)(line.amount) !== amount ||
|
|
335
|
+
line.currency !== data.currency ||
|
|
336
|
+
(0, fintoc_http_client_1.fintocInteger)(line.quantity) !== 1 ||
|
|
337
|
+
line.period_start !== null ||
|
|
338
|
+
line.period_end !== null)
|
|
339
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
340
|
+
if (data.external_payment !== undefined &&
|
|
341
|
+
typeof data.external_payment !== 'boolean')
|
|
342
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID');
|
|
343
|
+
const servicePeriod = period(metadata.service_period_start, metadata.service_period_end);
|
|
344
|
+
const confirmed = data.status === 'paid' &&
|
|
345
|
+
data.external_payment === false &&
|
|
346
|
+
data.payments.some((value) => {
|
|
347
|
+
const payment = (0, fintoc_http_client_1.fintocObject)(value);
|
|
348
|
+
if (payment.status !== 'succeeded' ||
|
|
349
|
+
payment.amount !== amount ||
|
|
350
|
+
payment.currency !== data.currency)
|
|
351
|
+
return false;
|
|
352
|
+
try {
|
|
353
|
+
(0, fintoc_http_client_1.fintocIdentifier)(payment.payment_intent, 'pi');
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
id: (0, fintoc_http_client_1.fintocIdentifier)(data.id, 'inv'),
|
|
362
|
+
customerId: (0, fintoc_http_client_1.fintocIdentifier)(data.customer, 'cus'),
|
|
363
|
+
paymentMethodId: (0, fintoc_http_client_1.fintocIdentifier)(data.default_payment_method, 'pm'),
|
|
364
|
+
localOperationId: operationId(metadata.operation_external_id),
|
|
365
|
+
mode: this.transport.mode(data.mode),
|
|
366
|
+
status: data.status,
|
|
367
|
+
amount,
|
|
368
|
+
currency: 'CLP',
|
|
369
|
+
paymentConfirmed: confirmed,
|
|
370
|
+
externalPayment: typeof data.external_payment === 'boolean'
|
|
371
|
+
? data.external_payment
|
|
372
|
+
: null,
|
|
373
|
+
attemptCount: (0, fintoc_http_client_1.fintocInteger)(data.attempt_count),
|
|
374
|
+
hostedUrl: this.transport.hostedUrl(data.hosted_invoice_url),
|
|
375
|
+
servicePeriodStart: servicePeriod.start,
|
|
376
|
+
servicePeriodEnd: servicePeriod.end,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
assertInvoice(invoice, expected) {
|
|
380
|
+
const servicePeriod = period(expected.periodStart, expected.periodEnd);
|
|
381
|
+
if (invoice.customerId !== expected.customerId ||
|
|
382
|
+
invoice.paymentMethodId !== expected.paymentMethodId ||
|
|
383
|
+
invoice.localOperationId !== expected.localOperationId ||
|
|
384
|
+
invoice.amount !== expected.amount ||
|
|
385
|
+
invoice.currency !== expected.currency ||
|
|
386
|
+
invoice.servicePeriodStart !== servicePeriod.start ||
|
|
387
|
+
invoice.servicePeriodEnd !== servicePeriod.end)
|
|
388
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
389
|
+
}
|
|
390
|
+
paymentMethodSetup(value) {
|
|
391
|
+
const data = (0, fintoc_http_client_1.fintocObject)(value);
|
|
392
|
+
if (data.flow !== 'setup' ||
|
|
393
|
+
data.amount !== null ||
|
|
394
|
+
!['created', 'in_progress', 'finished', 'expired'].includes(String(data.status)))
|
|
395
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SETUP_MISMATCH');
|
|
396
|
+
const setup = {
|
|
397
|
+
id: (0, fintoc_http_client_1.fintocIdentifier)(data.id, 'cs'),
|
|
398
|
+
customerId: (0, fintoc_http_client_1.fintocIdentifier)(data.customer, 'cus'),
|
|
399
|
+
subscriptionId: (0, fintoc_http_client_1.fintocIdentifier)(data.subscription, 'sub'),
|
|
400
|
+
localOperationId: operationId((0, fintoc_http_client_1.fintocObject)(data.metadata).operation_external_id),
|
|
401
|
+
mode: this.transport.mode(data.mode),
|
|
402
|
+
status: data.status,
|
|
403
|
+
paymentMethodId: data.payment_method === null
|
|
404
|
+
? null
|
|
405
|
+
: (0, fintoc_http_client_1.fintocIdentifier)(data.payment_method, 'pm'),
|
|
406
|
+
redirectUrl: this.transport.hostedUrl(data.redirect_url),
|
|
407
|
+
};
|
|
408
|
+
if (['created', 'in_progress'].includes(setup.status) && !setup.redirectUrl)
|
|
409
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SETUP_MISMATCH');
|
|
410
|
+
return setup;
|
|
411
|
+
}
|
|
412
|
+
async confirmSetup(setup) {
|
|
413
|
+
if (!setup.paymentMethodId)
|
|
414
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SETUP_MISMATCH');
|
|
415
|
+
await this.assertPaymentMethod(setup.paymentMethodId, setup.customerId);
|
|
416
|
+
const subscription = await this.getRecurringTerms(setup.subscriptionId);
|
|
417
|
+
this.assertSubscriptionOwner(subscription, setup.customerId);
|
|
418
|
+
if (subscription.paymentMethodId !== setup.paymentMethodId)
|
|
419
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SETUP_MISMATCH');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
exports.FintocBillingProvider = FintocBillingProvider;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FintocBillingProviderOptions } from './fintoc-provider.options';
|
|
2
|
+
import type { BillingEnvironment } from '@devly-cl/billing-core';
|
|
3
|
+
export type FintocJson = Record<string, unknown>;
|
|
4
|
+
export declare const fintocObject: (value: unknown) => FintocJson;
|
|
5
|
+
export declare const fintocString: (value: unknown) => string;
|
|
6
|
+
export declare const fintocIdentifier: (value: unknown, prefix: string) => string;
|
|
7
|
+
export declare const fintocInteger: (value: unknown) => number;
|
|
8
|
+
export declare const billingIdempotencyKey: (key: string) => string;
|
|
9
|
+
/** Shared native-fetch transport; performs one attempt, preserving uncertain mutations for reconciliation. */
|
|
10
|
+
export declare class FintocHttpClient {
|
|
11
|
+
private readonly options;
|
|
12
|
+
private readonly environment;
|
|
13
|
+
constructor(options: FintocBillingProviderOptions, environment: BillingEnvironment);
|
|
14
|
+
credentials(): {
|
|
15
|
+
key: string;
|
|
16
|
+
mode: BillingEnvironment;
|
|
17
|
+
};
|
|
18
|
+
assertCollectionReady(): void;
|
|
19
|
+
webhookSecret(): string;
|
|
20
|
+
now(): number;
|
|
21
|
+
mode(value: unknown): BillingEnvironment;
|
|
22
|
+
hostedUrl(value: unknown): string | null;
|
|
23
|
+
returnUrl(kind: 'success' | 'cancel'): string;
|
|
24
|
+
request(path: string, method?: 'GET' | 'POST' | 'PATCH', body?: FintocJson, key?: string): Promise<unknown>;
|
|
25
|
+
private readJson;
|
|
26
|
+
mutation<T>(request: () => Promise<unknown>, reconcile: (raw: unknown) => Promise<T> | T): Promise<T>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FintocHttpClient = exports.billingIdempotencyKey = exports.fintocInteger = exports.fintocIdentifier = exports.fintocString = exports.fintocObject = void 0;
|
|
4
|
+
const billing_core_1 = require("@devly-cl/billing-core");
|
|
5
|
+
const MAX_RESPONSE_BYTES = 1_048_576;
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
7
|
+
function withAbortDeadline(request, signal) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
let settled = false;
|
|
10
|
+
const settle = (action) => {
|
|
11
|
+
if (settled)
|
|
12
|
+
return;
|
|
13
|
+
settled = true;
|
|
14
|
+
signal.removeEventListener('abort', abort);
|
|
15
|
+
action();
|
|
16
|
+
};
|
|
17
|
+
const abort = () => settle(() => reject(new Error('Fintoc request deadline exceeded')));
|
|
18
|
+
if (signal.aborted)
|
|
19
|
+
abort();
|
|
20
|
+
else
|
|
21
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
22
|
+
void request.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error instanceof Error
|
|
23
|
+
? error
|
|
24
|
+
: new Error('Fintoc transport failed'))));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function discardResponseBody(response) {
|
|
28
|
+
if (response.body)
|
|
29
|
+
void response.body.cancel().catch(() => undefined);
|
|
30
|
+
}
|
|
31
|
+
const fintocObject = (value) => {
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
33
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID');
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
exports.fintocObject = fintocObject;
|
|
37
|
+
const fintocString = (value) => {
|
|
38
|
+
if (typeof value !== 'string' || !value.trim())
|
|
39
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID');
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
exports.fintocString = fintocString;
|
|
43
|
+
const fintocIdentifier = (value, prefix) => {
|
|
44
|
+
const id = typeof value === 'object' && value ? (0, exports.fintocObject)(value).id : value;
|
|
45
|
+
if (typeof id !== 'string' ||
|
|
46
|
+
!new RegExp(`^${prefix}_[A-Za-z0-9]+$`).test(id))
|
|
47
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_ID_INVALID');
|
|
48
|
+
return id;
|
|
49
|
+
};
|
|
50
|
+
exports.fintocIdentifier = fintocIdentifier;
|
|
51
|
+
const fintocInteger = (value) => {
|
|
52
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0)
|
|
53
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID');
|
|
54
|
+
return Number(value);
|
|
55
|
+
};
|
|
56
|
+
exports.fintocInteger = fintocInteger;
|
|
57
|
+
const billingIdempotencyKey = (key) => {
|
|
58
|
+
if (typeof key !== 'string' || !/^[\x21-\x7e]{1,255}$/.test(key))
|
|
59
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_IDEMPOTENCY_KEY_REQUIRED');
|
|
60
|
+
return key;
|
|
61
|
+
};
|
|
62
|
+
exports.billingIdempotencyKey = billingIdempotencyKey;
|
|
63
|
+
/** Shared native-fetch transport; performs one attempt, preserving uncertain mutations for reconciliation. */
|
|
64
|
+
class FintocHttpClient {
|
|
65
|
+
options;
|
|
66
|
+
environment;
|
|
67
|
+
constructor(options, environment) {
|
|
68
|
+
this.options = options;
|
|
69
|
+
this.environment = environment;
|
|
70
|
+
}
|
|
71
|
+
credentials() {
|
|
72
|
+
const key = this.options.getSecretKey();
|
|
73
|
+
if (typeof key !== 'string' ||
|
|
74
|
+
!/^sk_(test|live)_[A-Za-z0-9_-]+$/.test(key) ||
|
|
75
|
+
!key.startsWith(`sk_${this.environment}_`))
|
|
76
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_CONFIGURATION_REQUIRED');
|
|
77
|
+
return { key, mode: this.environment };
|
|
78
|
+
}
|
|
79
|
+
assertCollectionReady() {
|
|
80
|
+
this.options.beforeRequest?.({
|
|
81
|
+
method: 'POST',
|
|
82
|
+
path: '/checkout_sessions',
|
|
83
|
+
collection: true,
|
|
84
|
+
});
|
|
85
|
+
this.credentials();
|
|
86
|
+
}
|
|
87
|
+
webhookSecret() {
|
|
88
|
+
this.credentials();
|
|
89
|
+
const secret = this.options.getWebhookSecret();
|
|
90
|
+
if (typeof secret !== 'string' || !secret)
|
|
91
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_INVALID');
|
|
92
|
+
return secret;
|
|
93
|
+
}
|
|
94
|
+
now() {
|
|
95
|
+
return this.options.now ? this.options.now() : Date.now();
|
|
96
|
+
}
|
|
97
|
+
mode(value) {
|
|
98
|
+
const expected = this.credentials().mode;
|
|
99
|
+
if (value !== expected)
|
|
100
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_MODE_MISMATCH');
|
|
101
|
+
return expected;
|
|
102
|
+
}
|
|
103
|
+
hostedUrl(value) {
|
|
104
|
+
if (value === null || value === undefined)
|
|
105
|
+
return null;
|
|
106
|
+
let url;
|
|
107
|
+
try {
|
|
108
|
+
url = new URL((0, exports.fintocString)(value));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_URL_INVALID');
|
|
112
|
+
}
|
|
113
|
+
if (url.protocol !== 'https:' ||
|
|
114
|
+
url.username ||
|
|
115
|
+
url.password ||
|
|
116
|
+
url.port ||
|
|
117
|
+
!(url.hostname === 'pay.fintoc.com' ||
|
|
118
|
+
url.hostname === 'checkout.fintoc.com' ||
|
|
119
|
+
/^[a-z0-9-]+\.billing\.fintoc\.com$/.test(url.hostname)))
|
|
120
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_URL_INVALID');
|
|
121
|
+
return url.toString();
|
|
122
|
+
}
|
|
123
|
+
returnUrl(kind) {
|
|
124
|
+
let url;
|
|
125
|
+
try {
|
|
126
|
+
url = new URL((0, exports.fintocString)(this.options.getReturnUrl(kind)));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_RETURN_URL_INVALID');
|
|
130
|
+
}
|
|
131
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
132
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_RETURN_URL_INVALID');
|
|
133
|
+
return url.toString();
|
|
134
|
+
}
|
|
135
|
+
async request(path, method = 'GET', body, key) {
|
|
136
|
+
const mutation = method !== 'GET';
|
|
137
|
+
// Disabling new collections must not stop canonical reads, webhook checks,
|
|
138
|
+
// or an already authorized cancellation of renewal.
|
|
139
|
+
const cancellation = method === 'POST' &&
|
|
140
|
+
/^\/subscriptions\/sub_[A-Za-z0-9]+\/cancel$/.test(path);
|
|
141
|
+
this.options.beforeRequest?.({
|
|
142
|
+
method,
|
|
143
|
+
path,
|
|
144
|
+
collection: mutation && !cancellation,
|
|
145
|
+
});
|
|
146
|
+
const credentials = this.credentials();
|
|
147
|
+
if (mutation)
|
|
148
|
+
(0, exports.billingIdempotencyKey)(key ?? '');
|
|
149
|
+
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
const url = `https://api.fintoc.com/v2${path}`;
|
|
153
|
+
const init = {
|
|
154
|
+
method,
|
|
155
|
+
redirect: 'error',
|
|
156
|
+
signal,
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: credentials.key,
|
|
159
|
+
'Fintoc-Version': '2026-02-01',
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
...(key ? { 'Idempotency-Key': key } : {}),
|
|
162
|
+
},
|
|
163
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
164
|
+
};
|
|
165
|
+
const transport = this.options.transport;
|
|
166
|
+
response = await withAbortDeadline(transport === undefined || transport === null
|
|
167
|
+
? globalThis.fetch(url, init)
|
|
168
|
+
: transport.call(this.options, url, init), signal);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_UNCERTAIN', mutation);
|
|
172
|
+
}
|
|
173
|
+
// 408/409/429 and server failures cannot prove that a concurrent/in-flight mutation was not accepted.
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
discardResponseBody(response);
|
|
176
|
+
throw new billing_core_1.SubscriptionGatewayError(`BILLING_PROVIDER_HTTP_${response.status}`, mutation &&
|
|
177
|
+
(response.status >= 500 || [408, 409, 429].includes(response.status)));
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
return await withAbortDeadline(this.readJson(response, signal), signal);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID', mutation);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async readJson(response, signal) {
|
|
187
|
+
const declaredLength = response.headers.get('content-length');
|
|
188
|
+
if (declaredLength &&
|
|
189
|
+
(!/^\d+$/.test(declaredLength) ||
|
|
190
|
+
Number(declaredLength) > MAX_RESPONSE_BYTES)) {
|
|
191
|
+
discardResponseBody(response);
|
|
192
|
+
throw new Error('Response exceeds limit');
|
|
193
|
+
}
|
|
194
|
+
if (!response.body)
|
|
195
|
+
throw new Error('Empty response');
|
|
196
|
+
const reader = response.body.getReader();
|
|
197
|
+
const abort = () => {
|
|
198
|
+
void reader.cancel().catch(() => undefined);
|
|
199
|
+
};
|
|
200
|
+
if (signal.aborted)
|
|
201
|
+
abort();
|
|
202
|
+
else
|
|
203
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
204
|
+
const chunks = [];
|
|
205
|
+
let length = 0;
|
|
206
|
+
try {
|
|
207
|
+
while (true) {
|
|
208
|
+
const { done, value } = await reader.read();
|
|
209
|
+
if (done)
|
|
210
|
+
break;
|
|
211
|
+
length += value.byteLength;
|
|
212
|
+
if (length > MAX_RESPONSE_BYTES) {
|
|
213
|
+
void reader.cancel().catch(() => undefined);
|
|
214
|
+
throw new Error('Response exceeds limit');
|
|
215
|
+
}
|
|
216
|
+
chunks.push(Buffer.from(value));
|
|
217
|
+
}
|
|
218
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, length)));
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
signal.removeEventListener('abort', abort);
|
|
222
|
+
reader.releaseLock();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async mutation(request, reconcile) {
|
|
226
|
+
const raw = await request();
|
|
227
|
+
try {
|
|
228
|
+
return await reconcile(raw);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error instanceof billing_core_1.SubscriptionGatewayError)
|
|
232
|
+
throw new billing_core_1.SubscriptionGatewayError(error.code, true);
|
|
233
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID', true);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.FintocHttpClient = FintocHttpClient;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference lib="dom" preserve="true" />
|
|
2
|
+
import type { BillingEnvironment } from '@devly-cl/billing-core';
|
|
3
|
+
export type FintocTransport = (url: string, init: RequestInit) => Promise<Response>;
|
|
4
|
+
export interface FintocRequestContext {
|
|
5
|
+
readonly method: 'GET' | 'POST' | 'PATCH';
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly collection: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Dynamic host policy. Environment is fixed at construction; getters run only when needed. */
|
|
10
|
+
export interface FintocBillingProviderOptions {
|
|
11
|
+
readonly environment: BillingEnvironment;
|
|
12
|
+
getSecretKey(): unknown;
|
|
13
|
+
getWebhookSecret(): unknown;
|
|
14
|
+
getReturnUrl(kind: 'success' | 'cancel'): unknown;
|
|
15
|
+
beforeRequest?(request: FintocRequestContext): void;
|
|
16
|
+
transport?: FintocTransport;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { FintocHttpClient } from './fintoc-http.client';
|
|
2
|
+
import { ProviderInvoice, ProviderSubscription, SubscriptionCheckout, SubscriptionCheckoutInput, SubscriptionGateway } from '@devly-cl/billing-core';
|
|
3
|
+
/** Internal subscription operations; composed with the provider, never inherited. */
|
|
4
|
+
export declare class FintocSubscriptionGateway implements SubscriptionGateway {
|
|
5
|
+
private readonly transport;
|
|
6
|
+
constructor(transport: FintocHttpClient);
|
|
7
|
+
createCheckout(input: SubscriptionCheckoutInput): Promise<SubscriptionCheckout>;
|
|
8
|
+
getCheckout(id: string): Promise<SubscriptionCheckout>;
|
|
9
|
+
getSubscription(id: string): Promise<ProviderSubscription>;
|
|
10
|
+
cancelSubscription(id: string, key: string): Promise<ProviderSubscription>;
|
|
11
|
+
getInvoice(id: string): Promise<ProviderInvoice>;
|
|
12
|
+
listInvoices(subscriptionId: string, cursor?: string): Promise<{
|
|
13
|
+
items: ProviderInvoice[];
|
|
14
|
+
nextCursor: string | null;
|
|
15
|
+
}>;
|
|
16
|
+
verifyWebhook(rawBody: Buffer, signature: string | undefined): {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
resourceId: string;
|
|
20
|
+
mode: 'test' | 'live';
|
|
21
|
+
};
|
|
22
|
+
private checkout;
|
|
23
|
+
private subscription;
|
|
24
|
+
private invoice;
|
|
25
|
+
private hostedUrl;
|
|
26
|
+
private returnUrl;
|
|
27
|
+
private mode;
|
|
28
|
+
private credentials;
|
|
29
|
+
private request;
|
|
30
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FintocSubscriptionGateway = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const fintoc_http_client_1 = require("./fintoc-http.client");
|
|
6
|
+
const billing_core_1 = require("@devly-cl/billing-core");
|
|
7
|
+
/** Date.parse normalizes impossible calendar values, so validate RFC 3339 components first. */
|
|
8
|
+
const strictIsoInstant = (value) => {
|
|
9
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
10
|
+
if (!match)
|
|
11
|
+
return null;
|
|
12
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]), hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6] ?? 0);
|
|
13
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
14
|
+
const daysInMonth = [
|
|
15
|
+
31,
|
|
16
|
+
leapYear ? 29 : 28,
|
|
17
|
+
31,
|
|
18
|
+
30,
|
|
19
|
+
31,
|
|
20
|
+
30,
|
|
21
|
+
31,
|
|
22
|
+
31,
|
|
23
|
+
30,
|
|
24
|
+
31,
|
|
25
|
+
30,
|
|
26
|
+
31,
|
|
27
|
+
][month - 1];
|
|
28
|
+
if (year < 1 ||
|
|
29
|
+
month < 1 ||
|
|
30
|
+
month > 12 ||
|
|
31
|
+
day < 1 ||
|
|
32
|
+
day > daysInMonth ||
|
|
33
|
+
hour > 23 ||
|
|
34
|
+
minute > 59 ||
|
|
35
|
+
second > 59)
|
|
36
|
+
return null;
|
|
37
|
+
if (match[7] !== 'Z') {
|
|
38
|
+
const [offsetHour, offsetMinute] = match[7].slice(1).split(':').map(Number);
|
|
39
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const instant = Date.parse(value);
|
|
43
|
+
return Number.isFinite(instant) ? instant : null;
|
|
44
|
+
};
|
|
45
|
+
/** Internal subscription operations; composed with the provider, never inherited. */
|
|
46
|
+
class FintocSubscriptionGateway {
|
|
47
|
+
transport;
|
|
48
|
+
constructor(transport) {
|
|
49
|
+
this.transport = transport;
|
|
50
|
+
}
|
|
51
|
+
async createCheckout(input) {
|
|
52
|
+
if (!Number.isSafeInteger(input.amount) ||
|
|
53
|
+
input.amount < 1 ||
|
|
54
|
+
input.currency !== 'CLP' ||
|
|
55
|
+
!['month', 'year'].includes(input.interval))
|
|
56
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_TERMS_INVALID');
|
|
57
|
+
const successUrl = this.returnUrl('success');
|
|
58
|
+
const cancelUrl = this.returnUrl('cancel');
|
|
59
|
+
return this.transport.mutation(() => this.request('/checkout_sessions', 'POST', {
|
|
60
|
+
flow: 'subscription',
|
|
61
|
+
currency: input.currency,
|
|
62
|
+
success_url: successUrl,
|
|
63
|
+
cancel_url: cancelUrl,
|
|
64
|
+
payment_method_types: ['pac', 'card'],
|
|
65
|
+
customer_data: {
|
|
66
|
+
name: input.customer.name,
|
|
67
|
+
email: input.customer.email,
|
|
68
|
+
tax_id: { type: 'cl_rut', value: input.customer.taxId },
|
|
69
|
+
},
|
|
70
|
+
line_items: [
|
|
71
|
+
{
|
|
72
|
+
price_data: {
|
|
73
|
+
currency: input.currency,
|
|
74
|
+
unit_amount: input.amount,
|
|
75
|
+
product_data: { name: input.label },
|
|
76
|
+
recurring: { interval: input.interval, interval_count: 1 },
|
|
77
|
+
},
|
|
78
|
+
quantity: 1,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
metadata: {
|
|
82
|
+
subscription_external_id: input.localSubscriptionId,
|
|
83
|
+
tenant_id: input.tenantId,
|
|
84
|
+
},
|
|
85
|
+
}, input.idempotencyKey), (raw) => {
|
|
86
|
+
const result = this.checkout(raw);
|
|
87
|
+
if (result.amount !== input.amount ||
|
|
88
|
+
result.currency !== input.currency ||
|
|
89
|
+
result.localSubscriptionId !== input.localSubscriptionId)
|
|
90
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_CHECKOUT_MISMATCH', true);
|
|
91
|
+
return result;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async getCheckout(id) {
|
|
95
|
+
const result = this.checkout(await this.request(`/checkout_sessions/${(0, fintoc_http_client_1.fintocIdentifier)(id, 'cs')}`));
|
|
96
|
+
if (result.id !== id)
|
|
97
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_CHECKOUT_MISMATCH');
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
async getSubscription(id) {
|
|
101
|
+
const result = this.subscription(await this.request(`/subscriptions/${(0, fintoc_http_client_1.fintocIdentifier)(id, 'sub')}`));
|
|
102
|
+
if (result.id !== id)
|
|
103
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_SUBSCRIPTION_MISMATCH');
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
async cancelSubscription(id, key) {
|
|
107
|
+
(0, fintoc_http_client_1.billingIdempotencyKey)(key);
|
|
108
|
+
// A prior cancellation with an uncertain response is resolved by reading first.
|
|
109
|
+
const current = await this.getSubscription(id);
|
|
110
|
+
if (current.status === 'canceled' || current.status === 'cancelled')
|
|
111
|
+
return current;
|
|
112
|
+
return this.transport.mutation(() => this.request(`/subscriptions/${(0, fintoc_http_client_1.fintocIdentifier)(id, 'sub')}/cancel`, 'POST', {}, key), (raw) => {
|
|
113
|
+
const result = this.subscription(raw);
|
|
114
|
+
if (result.id !== id ||
|
|
115
|
+
result.customerId !== current.customerId ||
|
|
116
|
+
!['canceled', 'cancelled'].includes(result.status))
|
|
117
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_CANCELLATION_MISMATCH');
|
|
118
|
+
return result;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async getInvoice(id) {
|
|
122
|
+
const result = this.invoice(await this.request(`/invoices/${(0, fintoc_http_client_1.fintocIdentifier)(id, 'inv')}`));
|
|
123
|
+
if (result.id !== id)
|
|
124
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
async listInvoices(subscriptionId, cursor) {
|
|
128
|
+
const query = new URLSearchParams({
|
|
129
|
+
subscription: (0, fintoc_http_client_1.fintocIdentifier)(subscriptionId, 'sub'),
|
|
130
|
+
limit: '50',
|
|
131
|
+
});
|
|
132
|
+
if (cursor)
|
|
133
|
+
query.set('starting_after', (0, fintoc_http_client_1.fintocIdentifier)(cursor, 'inv'));
|
|
134
|
+
const raw = await this.request(`/invoices?${query.toString()}`);
|
|
135
|
+
if (!Array.isArray(raw) || raw.length > 50)
|
|
136
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_RESPONSE_INVALID');
|
|
137
|
+
const items = raw.map((item) => this.invoice(item));
|
|
138
|
+
if (items.some((item) => item.subscriptionId !== subscriptionId))
|
|
139
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
140
|
+
return {
|
|
141
|
+
items,
|
|
142
|
+
nextCursor: items.length === 50 ? items[items.length - 1].id : null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
verifyWebhook(rawBody, signature) {
|
|
146
|
+
this.credentials();
|
|
147
|
+
const secret = this.transport.webhookSecret();
|
|
148
|
+
if (!secret ||
|
|
149
|
+
!signature ||
|
|
150
|
+
!Buffer.isBuffer(rawBody) ||
|
|
151
|
+
rawBody.length > 1_048_576)
|
|
152
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_INVALID');
|
|
153
|
+
const parts = signature.split(',').map((part) => part.trim().split('='));
|
|
154
|
+
const timestamps = parts.filter(([key]) => key === 't');
|
|
155
|
+
if (timestamps.length !== 1 || !/^\d+$/.test(timestamps[0][1] ?? ''))
|
|
156
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_INVALID');
|
|
157
|
+
const timestamp = Number(timestamps[0][1]);
|
|
158
|
+
const now = this.transport.now();
|
|
159
|
+
if (!Number.isSafeInteger(timestamp) ||
|
|
160
|
+
!Number.isFinite(now) ||
|
|
161
|
+
Math.abs(now / 1000 - timestamp) > 300)
|
|
162
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_EXPIRED');
|
|
163
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', secret)
|
|
164
|
+
.update(`${timestamp}.`)
|
|
165
|
+
.update(rawBody)
|
|
166
|
+
.digest();
|
|
167
|
+
const valid = parts.some(([key, value]) => key === 'v1' &&
|
|
168
|
+
/^[a-f0-9]{64}$/i.test(value ?? '') &&
|
|
169
|
+
(0, node_crypto_1.timingSafeEqual)(expected, Buffer.from(value, 'hex')));
|
|
170
|
+
if (!valid)
|
|
171
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_INVALID');
|
|
172
|
+
let event;
|
|
173
|
+
try {
|
|
174
|
+
event = (0, fintoc_http_client_1.fintocObject)(JSON.parse(rawBody.toString('utf8')));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_WEBHOOK_INVALID');
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
id: (0, fintoc_http_client_1.fintocIdentifier)(event.id, 'evt'),
|
|
181
|
+
type: (0, fintoc_http_client_1.fintocString)(event.type),
|
|
182
|
+
resourceId: (0, fintoc_http_client_1.fintocString)((0, fintoc_http_client_1.fintocObject)(event.data).id),
|
|
183
|
+
mode: this.mode(event.mode),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
checkout(value) {
|
|
187
|
+
const data = (0, fintoc_http_client_1.fintocObject)(value);
|
|
188
|
+
const resource = data.subscription;
|
|
189
|
+
return {
|
|
190
|
+
id: (0, fintoc_http_client_1.fintocIdentifier)(data.id, 'cs'),
|
|
191
|
+
redirectUrl: this.hostedUrl(data.redirect_url),
|
|
192
|
+
customerId: (0, fintoc_http_client_1.fintocIdentifier)(data.customer, 'cus'),
|
|
193
|
+
subscriptionId: resource ? (0, fintoc_http_client_1.fintocIdentifier)(resource, 'sub') : null,
|
|
194
|
+
status: (0, fintoc_http_client_1.fintocString)(data.status),
|
|
195
|
+
mode: this.mode(data.mode),
|
|
196
|
+
localSubscriptionId: (0, fintoc_http_client_1.fintocString)((0, fintoc_http_client_1.fintocObject)(data.metadata).subscription_external_id),
|
|
197
|
+
amount: (0, fintoc_http_client_1.fintocInteger)(data.amount),
|
|
198
|
+
currency: (0, fintoc_http_client_1.fintocString)(data.currency),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
subscription(value) {
|
|
202
|
+
const data = (0, fintoc_http_client_1.fintocObject)(value);
|
|
203
|
+
return {
|
|
204
|
+
id: (0, fintoc_http_client_1.fintocIdentifier)(data.id, 'sub'),
|
|
205
|
+
customerId: (0, fintoc_http_client_1.fintocIdentifier)(data.customer, 'cus'),
|
|
206
|
+
status: (0, fintoc_http_client_1.fintocString)(data.status),
|
|
207
|
+
mode: this.mode(data.mode),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
invoice(value) {
|
|
211
|
+
const data = (0, fintoc_http_client_1.fintocObject)(value);
|
|
212
|
+
if (!Array.isArray(data.lines) ||
|
|
213
|
+
data.lines.length !== 1 ||
|
|
214
|
+
!Array.isArray(data.payments) ||
|
|
215
|
+
!['draft', 'open', 'paid', 'void'].includes(String(data.status)) ||
|
|
216
|
+
typeof data.external_payment !== 'boolean')
|
|
217
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_UNSUPPORTED');
|
|
218
|
+
const line = (0, fintoc_http_client_1.fintocObject)(data.lines[0]);
|
|
219
|
+
const amount = (0, fintoc_http_client_1.fintocInteger)(data.total);
|
|
220
|
+
const currency = (0, fintoc_http_client_1.fintocString)(data.currency);
|
|
221
|
+
const quantity = (0, fintoc_http_client_1.fintocInteger)(line.quantity);
|
|
222
|
+
const start = (0, fintoc_http_client_1.fintocString)(line.period_start), end = (0, fintoc_http_client_1.fintocString)(line.period_end);
|
|
223
|
+
const startAt = strictIsoInstant(start), endAt = strictIsoInstant(end);
|
|
224
|
+
if (quantity !== 1 ||
|
|
225
|
+
(0, fintoc_http_client_1.fintocInteger)(line.amount) !== amount ||
|
|
226
|
+
line.currency !== currency ||
|
|
227
|
+
startAt === null ||
|
|
228
|
+
endAt === null ||
|
|
229
|
+
startAt >= endAt)
|
|
230
|
+
throw new billing_core_1.SubscriptionGatewayError('BILLING_PROVIDER_INVOICE_MISMATCH');
|
|
231
|
+
const confirmed = data.status === 'paid' &&
|
|
232
|
+
data.external_payment === false &&
|
|
233
|
+
data.payments.some((value) => {
|
|
234
|
+
const payment = (0, fintoc_http_client_1.fintocObject)(value);
|
|
235
|
+
if (payment.status !== 'succeeded' ||
|
|
236
|
+
payment.amount !== amount ||
|
|
237
|
+
payment.currency !== currency)
|
|
238
|
+
return false;
|
|
239
|
+
try {
|
|
240
|
+
(0, fintoc_http_client_1.fintocIdentifier)(payment.payment_intent, 'pi');
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
id: (0, fintoc_http_client_1.fintocIdentifier)(data.id, 'inv'),
|
|
249
|
+
subscriptionId: data.subscription
|
|
250
|
+
? (0, fintoc_http_client_1.fintocIdentifier)(data.subscription, 'sub')
|
|
251
|
+
: null,
|
|
252
|
+
customerId: (0, fintoc_http_client_1.fintocIdentifier)(data.customer, 'cus'),
|
|
253
|
+
mode: this.mode(data.mode),
|
|
254
|
+
status: data.status,
|
|
255
|
+
amount,
|
|
256
|
+
currency,
|
|
257
|
+
externalPayment: data.external_payment,
|
|
258
|
+
paymentConfirmed: confirmed,
|
|
259
|
+
attemptCount: (0, fintoc_http_client_1.fintocInteger)(data.attempt_count),
|
|
260
|
+
periodStart: start,
|
|
261
|
+
periodEnd: end,
|
|
262
|
+
quantity,
|
|
263
|
+
hostedUrl: this.hostedUrl(data.hosted_invoice_url),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
hostedUrl(value) {
|
|
267
|
+
return this.transport.hostedUrl(value);
|
|
268
|
+
}
|
|
269
|
+
returnUrl(kind) {
|
|
270
|
+
return this.transport.returnUrl(kind);
|
|
271
|
+
}
|
|
272
|
+
mode(value) {
|
|
273
|
+
return this.transport.mode(value);
|
|
274
|
+
}
|
|
275
|
+
credentials() {
|
|
276
|
+
return this.transport.credentials();
|
|
277
|
+
}
|
|
278
|
+
async request(path, method = 'GET', body, key) {
|
|
279
|
+
return this.transport.request(path, method, body, key);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
exports.FintocSubscriptionGateway = FintocSubscriptionGateway;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FintocBillingProvider = void 0;
|
|
4
|
+
var fintoc_billing_provider_1 = require("./fintoc-billing.provider");
|
|
5
|
+
Object.defineProperty(exports, "FintocBillingProvider", { enumerable: true, get: function () { return fintoc_billing_provider_1.FintocBillingProvider; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devly-cl/billing-fintoc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fintoc billing adapter with injected transport and host policy.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24.0.0"
|
|
21
|
+
},
|
|
22
|
+
"license": "UNLICENSED",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
26
|
+
"test": "node --test test/*.test.mjs",
|
|
27
|
+
"prepack": "npm --prefix ../.. run build:packages",
|
|
28
|
+
"prepublishOnly": "npm --prefix ../.. run verify:release && node ../../scripts/verify-release-publishable.mjs --package @devly-cl/billing-fintoc"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@devly-cl/billing-core": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/devly-cl/billing-packages.git",
|
|
40
|
+
"directory": "packages/billing-fintoc"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/devly-cl/billing-packages/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/devly-cl/billing-packages#readme"
|
|
46
|
+
}
|