@birtalanrobert/commerce 1.0.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/CHANGELOG.md +601 -0
- package/LICENSE +661 -0
- package/NOTICE +45 -0
- package/README.md +79 -0
- package/dist/deposits.d.ts +52 -0
- package/dist/deposits.d.ts.map +1 -0
- package/dist/deposits.js +71 -0
- package/dist/deposits.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/1789800000000-CreateCommerce.d.ts +38 -0
- package/dist/migrations/1789800000000-CreateCommerce.d.ts.map +1 -0
- package/dist/migrations/1789800000000-CreateCommerce.js +152 -0
- package/dist/migrations/1789800000000-CreateCommerce.js.map +1 -0
- package/dist/nestjs/commerce.service.d.ts +122 -0
- package/dist/nestjs/commerce.service.d.ts.map +1 -0
- package/dist/nestjs/commerce.service.js +337 -0
- package/dist/nestjs/commerce.service.js.map +1 -0
- package/dist/nestjs/index.d.ts +18 -0
- package/dist/nestjs/index.d.ts.map +1 -0
- package/dist/nestjs/index.js +27 -0
- package/dist/nestjs/index.js.map +1 -0
- package/dist/nestjs/payment.entity.d.ts +94 -0
- package/dist/nestjs/payment.entity.d.ts.map +1 -0
- package/dist/nestjs/payment.entity.js +181 -0
- package/dist/nestjs/payment.entity.js.map +1 -0
- package/dist/nestjs/payout-account.entity.d.ts +41 -0
- package/dist/nestjs/payout-account.entity.d.ts.map +1 -0
- package/dist/nestjs/payout-account.entity.js +83 -0
- package/dist/nestjs/payout-account.entity.js.map +1 -0
- package/dist/providers/port.d.ts +97 -0
- package/dist/providers/port.d.ts.map +1 -0
- package/dist/providers/port.js +16 -0
- package/dist/providers/port.js.map +1 -0
- package/dist/providers/stripe.d.ts +39 -0
- package/dist/providers/stripe.d.ts.map +1 -0
- package/dist/providers/stripe.js +221 -0
- package/dist/providers/stripe.js.map +1 -0
- package/nestjs/package.json +5 -0
- package/package.json +49 -0
- package/src/deposits.ts +96 -0
- package/src/index.ts +40 -0
- package/src/migrations/1789800000000-CreateCommerce.ts +156 -0
- package/src/nestjs/commerce.service.ts +476 -0
- package/src/nestjs/index.ts +24 -0
- package/src/nestjs/payment.entity.ts +150 -0
- package/src/nestjs/payout-account.entity.ts +56 -0
- package/src/providers/port.ts +108 -0
- package/src/providers/stripe.ts +274 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Column, Entity, Index, Unique } from 'typeorm';
|
|
2
|
+
import { BaseEntity } from '@birtalanrobert/database';
|
|
3
|
+
import type { PayoutStatus } from '../deposits';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a business's money goes, and whether the provider will send it yet.
|
|
7
|
+
*
|
|
8
|
+
* **We never hold anybody's funds.** A customer pays the business directly and
|
|
9
|
+
* our fee is taken on top as an application fee — which is a hard architectural
|
|
10
|
+
* rule rather than a preference, because holding third-party money turns a
|
|
11
|
+
* software company into a regulated payments business.
|
|
12
|
+
*
|
|
13
|
+
* The consequence is this table. Until the provider has verified who the
|
|
14
|
+
* business is, there is nowhere for a payment to land, so every product that
|
|
15
|
+
* takes money on somebody's behalf has to gate its selling on the same fact.
|
|
16
|
+
*/
|
|
17
|
+
@Entity('mortar_payout_accounts')
|
|
18
|
+
@Unique('uq_payout_accounts_tenant', ['tenantId', 'provider'])
|
|
19
|
+
@Index('ix_payout_accounts_external', ['provider', 'externalId'])
|
|
20
|
+
export class PayoutAccount extends BaseEntity {
|
|
21
|
+
@Column('uuid')
|
|
22
|
+
tenantId!: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Which provider this account is with.
|
|
26
|
+
*
|
|
27
|
+
* A column rather than an assumption, because the markets differ: a local
|
|
28
|
+
* processor with faster onboarding beats a lower fee for a restaurant that
|
|
29
|
+
* wants to be live this afternoon, and one of the seventeen will need one.
|
|
30
|
+
*/
|
|
31
|
+
@Column('varchar', { length: 32, default: 'stripe' })
|
|
32
|
+
provider!: string;
|
|
33
|
+
|
|
34
|
+
/** The provider's own identifier for the account. */
|
|
35
|
+
@Column('varchar', { length: 128 })
|
|
36
|
+
externalId!: string;
|
|
37
|
+
|
|
38
|
+
@Column('varchar', { length: 16, default: 'pending' })
|
|
39
|
+
status!: PayoutStatus;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the provider still wants, in its own words.
|
|
43
|
+
*
|
|
44
|
+
* Kept verbatim rather than translated into a status of ours. "We need a
|
|
45
|
+
* photograph of the director's identity document" is actionable; "restricted"
|
|
46
|
+
* is a support conversation, and the difference is a business that finishes
|
|
47
|
+
* onboarding on a Sunday evening rather than on Tuesday when somebody
|
|
48
|
+
* telephones them.
|
|
49
|
+
*/
|
|
50
|
+
@Column('jsonb', { default: () => `'[]'::jsonb` })
|
|
51
|
+
requirements!: string[];
|
|
52
|
+
|
|
53
|
+
/** Set the first time the provider said it would pay out. */
|
|
54
|
+
@Column('timestamptz', { nullable: true })
|
|
55
|
+
readyAt!: Date | null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a payment provider has to be able to do, and nothing more.
|
|
3
|
+
*
|
|
4
|
+
* A port rather than the vendor's client, for the reason `@birtalanrobert/files`
|
|
5
|
+
* has one over S3: the day a market needs a local processor — and one will,
|
|
6
|
+
* because a restaurant that can be onboarded this afternoon beats a lower fee —
|
|
7
|
+
* nothing above this interface moves.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately small. Everything that can be decided without the provider is
|
|
10
|
+
* decided without it: what a deposit comes to, whether a business may sell yet,
|
|
11
|
+
* what a refund leaves. This is only the part that genuinely needs somebody
|
|
12
|
+
* else's money-moving licence.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Where a business's money goes, as the provider knows it. */
|
|
16
|
+
export interface ProviderAccount {
|
|
17
|
+
readonly externalId: string;
|
|
18
|
+
readonly status: 'pending' | 'restricted' | 'ready';
|
|
19
|
+
/** What the provider still wants, in its own words. */
|
|
20
|
+
readonly requirements: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OnboardingLink {
|
|
24
|
+
readonly url: string;
|
|
25
|
+
readonly expiresAt: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChargeRequest {
|
|
29
|
+
/** The business being paid, as the provider knows it. */
|
|
30
|
+
readonly account: string;
|
|
31
|
+
readonly amount: number;
|
|
32
|
+
readonly currency: string;
|
|
33
|
+
/** Our cut, taken on top rather than out of the business's money. */
|
|
34
|
+
readonly applicationFee: number;
|
|
35
|
+
/** What it is for, carried through so a webhook can be matched back. */
|
|
36
|
+
readonly subject: string;
|
|
37
|
+
/**
|
|
38
|
+
* Whether to take the money now or only hold it.
|
|
39
|
+
*
|
|
40
|
+
* Holding is the anti-no-show mechanism: a card is authorised and charged
|
|
41
|
+
* only if a fee is actually applied, and **that decision is a human one**.
|
|
42
|
+
*/
|
|
43
|
+
readonly capture: boolean;
|
|
44
|
+
readonly description?: string;
|
|
45
|
+
/** An idempotency key, so a retried request does not charge twice. */
|
|
46
|
+
readonly reference: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChargeResult {
|
|
50
|
+
readonly externalId: string;
|
|
51
|
+
readonly state: 'pending' | 'authorized' | 'captured' | 'failed';
|
|
52
|
+
/**
|
|
53
|
+
* Where to send the customer to finish, when the provider needs them.
|
|
54
|
+
*
|
|
55
|
+
* 3-D Secure and bank redirects are the ordinary case in both target
|
|
56
|
+
* markets rather than an exception, so a charge that returns a URL is not a
|
|
57
|
+
* failure and must not be handled as one.
|
|
58
|
+
*/
|
|
59
|
+
readonly redirectUrl?: string;
|
|
60
|
+
readonly instrument?: string;
|
|
61
|
+
readonly detail?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RefundRequest {
|
|
65
|
+
readonly externalId: string;
|
|
66
|
+
readonly amount: number;
|
|
67
|
+
readonly reason: string;
|
|
68
|
+
readonly reference: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a provider's webhook turned out to be about. */
|
|
72
|
+
export interface ProviderEvent {
|
|
73
|
+
readonly kind: 'payment' | 'account' | 'other';
|
|
74
|
+
readonly externalId: string;
|
|
75
|
+
readonly state?: 'authorized' | 'captured' | 'failed' | 'refunded';
|
|
76
|
+
readonly accountStatus?: ProviderAccount;
|
|
77
|
+
readonly instrument?: string;
|
|
78
|
+
readonly detail?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PaymentProvider {
|
|
82
|
+
readonly name: string;
|
|
83
|
+
|
|
84
|
+
/** Starts or resumes onboarding, and says where to send the business. */
|
|
85
|
+
onboard(tenantId: string, returnUrl: string, refreshUrl: string): Promise<OnboardingLink>;
|
|
86
|
+
|
|
87
|
+
/** Creates the account if there is none, and reports where it stands. */
|
|
88
|
+
account(externalId: string | null, country: string, email?: string): Promise<ProviderAccount>;
|
|
89
|
+
|
|
90
|
+
charge(request: ChargeRequest): Promise<ChargeResult>;
|
|
91
|
+
|
|
92
|
+
/** Takes money that was only held. The human decision has been made. */
|
|
93
|
+
capture(externalId: string, amount?: number): Promise<ChargeResult>;
|
|
94
|
+
|
|
95
|
+
/** Releases a hold without taking anything. */
|
|
96
|
+
release(externalId: string): Promise<void>;
|
|
97
|
+
|
|
98
|
+
refund(request: RefundRequest): Promise<{ externalId: string }>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether a webhook really came from the provider, and what it says.
|
|
102
|
+
*
|
|
103
|
+
* `undefined` rather than a thrown error: the caller's answer to a request
|
|
104
|
+
* that did not come from the provider is a flat acknowledgement, not a
|
|
105
|
+
* message describing what was wrong with the forgery.
|
|
106
|
+
*/
|
|
107
|
+
verify(payload: string | Buffer, signature: string | undefined): ProviderEvent | undefined;
|
|
108
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import type {
|
|
3
|
+
ChargeRequest,
|
|
4
|
+
ChargeResult,
|
|
5
|
+
OnboardingLink,
|
|
6
|
+
PaymentProvider,
|
|
7
|
+
ProviderAccount,
|
|
8
|
+
ProviderEvent,
|
|
9
|
+
RefundRequest,
|
|
10
|
+
} from './port';
|
|
11
|
+
|
|
12
|
+
export interface StripeConnectOptions {
|
|
13
|
+
secretKey: string;
|
|
14
|
+
/** The endpoint's signing secret, as the dashboard gives it: `whsec_…`. */
|
|
15
|
+
webhookSecret?: string;
|
|
16
|
+
/** An already-built client, for tests and for a deployment that shares one. */
|
|
17
|
+
client?: Stripe;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stripe Connect, as the port describes it.
|
|
22
|
+
*
|
|
23
|
+
* **Destination charges throughout.** The money is created on our platform
|
|
24
|
+
* account and transferred immediately to the business, with our cut taken as an
|
|
25
|
+
* application fee — which is what keeps this a software company rather than a
|
|
26
|
+
* regulated one, and what lets the business see its own payouts in its own
|
|
27
|
+
* Stripe dashboard.
|
|
28
|
+
*
|
|
29
|
+
* Everything the provider does *not* need to decide is decided before this file
|
|
30
|
+
* is reached: what a deposit comes to, whether a business may sell, what a
|
|
31
|
+
* refund leaves. What is here is the part that genuinely needs somebody else's
|
|
32
|
+
* money-moving licence.
|
|
33
|
+
*/
|
|
34
|
+
export class StripeConnect implements PaymentProvider {
|
|
35
|
+
readonly name = 'stripe';
|
|
36
|
+
private readonly stripe: Stripe;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly options: StripeConnectOptions) {
|
|
39
|
+
this.stripe = options.client ?? new Stripe(options.secretKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async account(
|
|
43
|
+
externalId: string | null,
|
|
44
|
+
country: string,
|
|
45
|
+
email?: string,
|
|
46
|
+
): Promise<ProviderAccount> {
|
|
47
|
+
const account = externalId
|
|
48
|
+
? await this.stripe.accounts.retrieve(externalId)
|
|
49
|
+
: await this.stripe.accounts.create({
|
|
50
|
+
type: 'express',
|
|
51
|
+
country,
|
|
52
|
+
...(email ? { email } : {}),
|
|
53
|
+
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return interpretAccount(account);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async onboard(
|
|
60
|
+
externalId: string,
|
|
61
|
+
returnUrl: string,
|
|
62
|
+
refreshUrl: string,
|
|
63
|
+
): Promise<OnboardingLink> {
|
|
64
|
+
const link = await this.stripe.accountLinks.create({
|
|
65
|
+
account: externalId,
|
|
66
|
+
type: 'account_onboarding',
|
|
67
|
+
return_url: returnUrl,
|
|
68
|
+
/*
|
|
69
|
+
* Where the business lands if the link has aged out.
|
|
70
|
+
*
|
|
71
|
+
* Stripe's onboarding links are short-lived and somebody *will* open one
|
|
72
|
+
* the next morning. Without this they meet an error page from a company
|
|
73
|
+
* they have never heard of, halfway through giving it their passport.
|
|
74
|
+
*/
|
|
75
|
+
refresh_url: refreshUrl,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { url: link.url, expiresAt: new Date(link.expires_at * 1000) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async charge(request: ChargeRequest): Promise<ChargeResult> {
|
|
82
|
+
try {
|
|
83
|
+
const intent = await this.stripe.paymentIntents.create(
|
|
84
|
+
{
|
|
85
|
+
amount: request.amount,
|
|
86
|
+
currency: request.currency.toLowerCase(),
|
|
87
|
+
/*
|
|
88
|
+
* The money lands on the business's account, not ours.
|
|
89
|
+
*
|
|
90
|
+
* `transfer_data.destination` with `application_fee_amount` is the
|
|
91
|
+
* destination-charge shape: we never hold their funds, and their
|
|
92
|
+
* payouts appear in their own dashboard.
|
|
93
|
+
*/
|
|
94
|
+
transfer_data: { destination: request.account },
|
|
95
|
+
...(request.applicationFee > 0 ? { application_fee_amount: request.applicationFee } : {}),
|
|
96
|
+
capture_method: request.capture ? 'automatic' : 'manual',
|
|
97
|
+
...(request.description ? { description: request.description } : {}),
|
|
98
|
+
// Carried through so a webhook can be matched back to what it paid
|
|
99
|
+
// for without a lookup table of our own.
|
|
100
|
+
metadata: { subject: request.subject },
|
|
101
|
+
automatic_payment_methods: { enabled: true },
|
|
102
|
+
},
|
|
103
|
+
// The provider's own idempotency, so a retried request — a timeout, a
|
|
104
|
+
// double submit — does not charge somebody twice.
|
|
105
|
+
{ idempotencyKey: request.reference },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return interpretIntent(intent);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return failure(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async capture(externalId: string, amount?: number): Promise<ChargeResult> {
|
|
115
|
+
try {
|
|
116
|
+
const intent = await this.stripe.paymentIntents.capture(
|
|
117
|
+
externalId,
|
|
118
|
+
amount === undefined ? undefined : { amount_to_capture: amount },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return interpretIntent(intent);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return failure(error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async release(externalId: string): Promise<void> {
|
|
128
|
+
await this.stripe.paymentIntents.cancel(externalId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async refund(request: RefundRequest): Promise<{ externalId: string }> {
|
|
132
|
+
const refund = await this.stripe.refunds.create(
|
|
133
|
+
{
|
|
134
|
+
payment_intent: request.externalId,
|
|
135
|
+
amount: request.amount,
|
|
136
|
+
metadata: { reason: request.reason.slice(0, 500) },
|
|
137
|
+
},
|
|
138
|
+
{ idempotencyKey: request.reference },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return { externalId: refund.id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
verify(payload: string | Buffer, signature: string | undefined): ProviderEvent | undefined {
|
|
145
|
+
if (!signature || !this.options.webhookSecret) return undefined;
|
|
146
|
+
|
|
147
|
+
let event: Stripe.Event;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
event = this.stripe.webhooks.constructEvent(payload, signature, this.options.webhookSecret);
|
|
151
|
+
} catch {
|
|
152
|
+
/*
|
|
153
|
+
* A forgery, or a payload something re-encoded on the way in.
|
|
154
|
+
*
|
|
155
|
+
* `undefined` rather than a thrown error: the caller answers a request
|
|
156
|
+
* that did not come from Stripe with a flat acknowledgement, not with a
|
|
157
|
+
* message describing what was wrong with it.
|
|
158
|
+
*/
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return interpretEvent(event);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Stripe's account shape, reduced to the question anybody actually asks. */
|
|
167
|
+
function interpretAccount(account: Stripe.Account): ProviderAccount {
|
|
168
|
+
const requirements = [
|
|
169
|
+
...(account.requirements?.currently_due ?? []),
|
|
170
|
+
...(account.requirements?.past_due ?? []),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
* `charges_enabled` and `payouts_enabled` together, not either alone.
|
|
175
|
+
*
|
|
176
|
+
* A business that can take money but cannot be paid out is worse than one
|
|
177
|
+
* that cannot sell yet: the customer is charged and the money sits with the
|
|
178
|
+
* provider, and the first anybody hears is the business asking where it is.
|
|
179
|
+
*/
|
|
180
|
+
const ready = account.charges_enabled === true && account.payouts_enabled === true;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
externalId: account.id,
|
|
184
|
+
status: ready ? 'ready' : requirements.length > 0 ? 'restricted' : 'pending',
|
|
185
|
+
requirements: [...new Set(requirements)],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function interpretIntent(intent: Stripe.PaymentIntent): ChargeResult {
|
|
190
|
+
const state =
|
|
191
|
+
intent.status === 'succeeded'
|
|
192
|
+
? 'captured'
|
|
193
|
+
: intent.status === 'requires_capture'
|
|
194
|
+
? 'authorized'
|
|
195
|
+
: intent.status === 'canceled'
|
|
196
|
+
? 'failed'
|
|
197
|
+
: 'pending';
|
|
198
|
+
|
|
199
|
+
const charge = intent.latest_charge;
|
|
200
|
+
const card =
|
|
201
|
+
typeof charge === 'object' && charge?.payment_method_details?.card
|
|
202
|
+
? `${charge.payment_method_details.card.brand} ending ${charge.payment_method_details.card.last4}`
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
externalId: intent.id,
|
|
207
|
+
state,
|
|
208
|
+
...(intent.next_action?.redirect_to_url?.url
|
|
209
|
+
? { redirectUrl: intent.next_action.redirect_to_url.url }
|
|
210
|
+
: {}),
|
|
211
|
+
...(card ? { instrument: card } : {}),
|
|
212
|
+
...(intent.last_payment_error?.message ? { detail: intent.last_payment_error.message } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function interpretEvent(event: Stripe.Event): ProviderEvent {
|
|
217
|
+
switch (event.type) {
|
|
218
|
+
case 'payment_intent.succeeded':
|
|
219
|
+
case 'payment_intent.amount_capturable_updated':
|
|
220
|
+
case 'payment_intent.payment_failed': {
|
|
221
|
+
const intent = event.data.object as Stripe.PaymentIntent;
|
|
222
|
+
const result = interpretIntent(intent);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
kind: 'payment',
|
|
226
|
+
externalId: intent.id,
|
|
227
|
+
state:
|
|
228
|
+
event.type === 'payment_intent.payment_failed'
|
|
229
|
+
? 'failed'
|
|
230
|
+
: result.state === 'captured'
|
|
231
|
+
? 'captured'
|
|
232
|
+
: 'authorized',
|
|
233
|
+
...(result.instrument ? { instrument: result.instrument } : {}),
|
|
234
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case 'charge.refunded': {
|
|
239
|
+
const charge = event.data.object as Stripe.Charge;
|
|
240
|
+
return {
|
|
241
|
+
kind: 'payment',
|
|
242
|
+
externalId: typeof charge.payment_intent === 'string' ? charge.payment_intent : charge.id,
|
|
243
|
+
state: 'refunded',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'account.updated': {
|
|
248
|
+
const account = event.data.object as Stripe.Account;
|
|
249
|
+
return { kind: 'account', externalId: account.id, accountStatus: interpretAccount(account) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
default:
|
|
253
|
+
/*
|
|
254
|
+
* Everything else is acknowledged and ignored.
|
|
255
|
+
*
|
|
256
|
+
* Stripe sends a great many event types and a deployment's subscription
|
|
257
|
+
* will drift; treating an unknown one as an error means retries and an
|
|
258
|
+
* alert for something that was never any of our business.
|
|
259
|
+
*/
|
|
260
|
+
return { kind: 'other', externalId: event.id };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A provider's refusal, as a result rather than an exception. */
|
|
265
|
+
function failure(error: unknown): ChargeResult {
|
|
266
|
+
const message =
|
|
267
|
+
error instanceof Stripe.errors.StripeError
|
|
268
|
+
? (error.message ?? 'The payment was refused.')
|
|
269
|
+
: error instanceof Error
|
|
270
|
+
? error.message
|
|
271
|
+
: 'The payment was refused.';
|
|
272
|
+
|
|
273
|
+
return { externalId: '', state: 'failed', detail: message };
|
|
274
|
+
}
|